Code Cleanup
This commit is contained in:
@@ -36,10 +36,10 @@ internal static class AdminEndpoints
|
||||
return TypedResults.Unauthorized();
|
||||
|
||||
if (!user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase))
|
||||
return ApiResultMapper.ToBadRequest(new ServiceError("forbidden", "Admin role is required."));
|
||||
return ApiResultMapper.ToBadRequest(new("forbidden", "Admin role is required."));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(databaseFile.Path) || !File.Exists(databaseFile.Path))
|
||||
return ApiResultMapper.ToBadRequest(new ServiceError("database_unavailable", "SQLite database file is not available."));
|
||||
return ApiResultMapper.ToBadRequest(new("database_unavailable", "SQLite database file is not available."));
|
||||
|
||||
var stream = new FileStream(databaseFile.Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
return TypedResults.File(stream, "application/octet-stream", Path.GetFileName(databaseFile.Path));
|
||||
@@ -47,4 +47,4 @@ internal static class AdminEndpoints
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,4 +18,4 @@ public static class ApiEndpointRegistration
|
||||
authenticatedApi.MapRollEndpoints();
|
||||
authenticatedApi.MapStateEventEndpoints();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,4 @@ internal static class ApiResultMapper
|
||||
{
|
||||
return TypedResults.BadRequest(new ApiError(error.Message, error.Code));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,4 +51,4 @@ internal static class CampaignEndpoints
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,4 +51,4 @@ internal static class CharacterEndpoints
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Services;
|
||||
|
||||
namespace RpgRoller.Api;
|
||||
@@ -15,4 +14,4 @@ internal static class RollEndpoints
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,4 +57,4 @@ internal static class SkillEndpoints
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,7 @@ 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, stateResult.Error.Code));
|
||||
return stateResult.Error!.Code == "unauthorized" ? TypedResults.Unauthorized() : TypedResults.BadRequest(new ApiError(stateResult.Error.Message, stateResult.Error.Code));
|
||||
}
|
||||
|
||||
context.Response.Headers.CacheControl = "no-cache";
|
||||
@@ -60,11 +58,8 @@ internal static class StateEventEndpoints
|
||||
|
||||
private static Task WriteStateEventAsync(HttpResponse response, CampaignStateSnapshot snapshot)
|
||||
{
|
||||
var characterVersions = string.Join(
|
||||
",",
|
||||
snapshot.CharacterVersions.Select(version => $"{{\"characterId\":\"{version.CharacterId}\",\"version\":{version.Version}}}"));
|
||||
var characterVersions = string.Join(",", snapshot.CharacterVersions.Select(version => $"{{\"characterId\":\"{version.CharacterId}\",\"version\":{version.Version}}}"));
|
||||
|
||||
return response.WriteAsync(
|
||||
$"event: state\ndata: {{\"campaignId\":\"{snapshot.CampaignId}\",\"totalVersion\":{snapshot.TotalVersion},\"rosterVersion\":{snapshot.RosterVersion},\"logVersion\":{snapshot.LogVersion},\"characterVersions\":[{characterVersions}]}}\n\n");
|
||||
return response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{snapshot.CampaignId}\",\"totalVersion\":{snapshot.TotalVersion},\"rosterVersion\":{snapshot.RosterVersion},\"logVersion\":{snapshot.LogVersion},\"characterVersions\":[{characterVersions}]}}\n\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,9 @@
|
||||
</html>
|
||||
|
||||
@code {
|
||||
|
||||
[CascadingParameter]
|
||||
private Microsoft.AspNetCore.Http.HttpContext? HttpContext { get; set; }
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string BaseHref
|
||||
{
|
||||
@@ -36,4 +37,5 @@
|
||||
return pathBase.EndsWith('/') ? pathBase : $"{pathBase}/";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
@inherits LayoutComponentBase
|
||||
@attribute [ExcludeFromCodeCoverage]
|
||||
|
||||
@Body
|
||||
@Body
|
||||
@@ -70,4 +70,4 @@ public enum HomeViewMode
|
||||
Loading,
|
||||
Anonymous,
|
||||
Workspace
|
||||
}
|
||||
}
|
||||
@@ -24,4 +24,4 @@
|
||||
case HomeViewMode.Workspace:
|
||||
<Workspace LoggedOut="OnLoggedOutAsync"/>
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -77,4 +77,4 @@ public partial class Home
|
||||
|
||||
[Inject]
|
||||
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -68,4 +68,4 @@
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,9 +28,7 @@ public partial class AdminHome
|
||||
if (!IsCurrentUserAdmin)
|
||||
return;
|
||||
|
||||
Users = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users"))
|
||||
.OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
Users = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users")).OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
catch (ApiRequestException ex) when (ex.StatusCode == 401)
|
||||
{
|
||||
@@ -92,10 +90,7 @@ public partial class AdminHome
|
||||
try
|
||||
{
|
||||
IReadOnlyList<string> roles = HasAdminRole(user) ? Array.Empty<string>() : [UserRoles.Admin];
|
||||
_ = await ApiClient.RequestAsync<AdminUserSummary>(
|
||||
"PUT",
|
||||
$"/api/admin/users/{user.Id}/roles",
|
||||
new UpdateUserRolesRequest(roles));
|
||||
_ = await ApiClient.RequestAsync<AdminUserSummary>("PUT", $"/api/admin/users/{user.Id}/roles", new UpdateUserRolesRequest(roles));
|
||||
|
||||
await ReloadUsersAsync();
|
||||
SetStatus("User roles updated.", false);
|
||||
@@ -138,9 +133,7 @@ public partial class AdminHome
|
||||
|
||||
private async Task ReloadUsersAsync()
|
||||
{
|
||||
Users = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users"))
|
||||
.OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
Users = (await ApiClient.RequestAsync<IReadOnlyList<AdminUserSummary>>("GET", "/api/admin/users")).OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
private static bool HasAdminRole(UserSummary user)
|
||||
@@ -184,22 +177,32 @@ public partial class AdminHome
|
||||
private List<AdminUserSummary> Users { get; set; } = [];
|
||||
private string? StatusMessage { get; set; }
|
||||
private bool StatusIsError { get; set; }
|
||||
private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems
|
||||
{
|
||||
get
|
||||
|
||||
private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems =>
|
||||
[
|
||||
new AppHeaderMenuItem
|
||||
{
|
||||
return
|
||||
[
|
||||
new AppHeaderMenuItem { Label = "Play", IsActive = false, OnSelected = OpenPlayAsync },
|
||||
new AppHeaderMenuItem { Label = "Campaign Management", IsActive = false, OnSelected = OpenCampaignManagementAsync },
|
||||
new AppHeaderMenuItem { Label = "Admin", IsActive = true, OnSelected = OpenAdminAsync }
|
||||
];
|
||||
Label = "Play",
|
||||
IsActive = false,
|
||||
OnSelected = OpenPlayAsync
|
||||
},
|
||||
new AppHeaderMenuItem
|
||||
{
|
||||
Label = "Campaign Management",
|
||||
IsActive = false,
|
||||
OnSelected = OpenCampaignManagementAsync
|
||||
},
|
||||
new AppHeaderMenuItem
|
||||
{
|
||||
Label = "Admin",
|
||||
IsActive = true,
|
||||
OnSelected = OpenAdminAsync
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string?> LoggedOut { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string> WorkspaceRequested { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,15 @@
|
||||
<h1>@Title</h1>
|
||||
@if (User is null)
|
||||
{
|
||||
<p class="header-identity"><strong>Loading user...</strong></p>
|
||||
<p class="header-identity">
|
||||
<strong>Loading user...</strong>
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="header-identity"><strong>@User.DisplayName</strong> <span class="muted">(@User.Username)</span></p>
|
||||
<p class="header-identity">
|
||||
<strong>@User.DisplayName</strong> <span class="muted">(@User.Username)</span>
|
||||
</p>
|
||||
}
|
||||
@if (ShowCampaign)
|
||||
{
|
||||
@@ -50,4 +54,4 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
@@ -57,4 +57,4 @@ public sealed class AppHeaderMenuItem
|
||||
public string Label { get; init; } = string.Empty;
|
||||
public bool IsActive { get; init; }
|
||||
public Func<Task>? OnSelected { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -63,4 +63,4 @@
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
@@ -1,5 +1,7 @@
|
||||
<aside @ref="LogPanelRef" class="card log-panel">
|
||||
<div class="section-head"><h2>Campaign Log</h2></div>
|
||||
<div class="section-head">
|
||||
<h2>Campaign Log</h2>
|
||||
</div>
|
||||
<div @ref="LogFeedRef" class="log-panel-feed">
|
||||
@if (IsCampaignDataLoading)
|
||||
{
|
||||
@@ -47,9 +49,12 @@
|
||||
}
|
||||
</span>
|
||||
}
|
||||
<span class="log-meta"><span class="badge @entry.VisibilityStyle">@entry.VisibilityLabel</span>
|
||||
<span class="log-meta">
|
||||
<span class="badge @entry.VisibilityStyle">@entry.VisibilityLabel</span>
|
||||
<time
|
||||
title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time>
|
||||
title="@entry.TimestampUtc.ToString("O")">
|
||||
@entry.TimestampUtc.ToLocalTime().ToString("g")
|
||||
</time>
|
||||
</span>
|
||||
</button>
|
||||
@if (isExpanded)
|
||||
@@ -78,29 +83,29 @@
|
||||
|
||||
<section class="custom-roll-panel" aria-label="Custom roll panel">
|
||||
<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>
|
||||
}
|
||||
<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>
|
||||
</section>
|
||||
</aside>
|
||||
</aside>
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using RpgRoller.Components;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
@@ -9,6 +8,8 @@ namespace RpgRoller.Components.Pages.HomeControls;
|
||||
[ExcludeFromCodeCoverage]
|
||||
public partial class CampaignLogPanel
|
||||
{
|
||||
private sealed record EventBadgeView(string Label, string Tone);
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
var currentLastRollId = CampaignLog.LastOrDefault()?.RollId;
|
||||
@@ -37,6 +38,96 @@ public partial class CampaignLogPanel
|
||||
LastRenderedLogRollId = currentLastRollId;
|
||||
}
|
||||
|
||||
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 ?? []).Select(ToEventBadgeView).Where(badge => badge is not null).Cast<EventBadgeView>().ToArray();
|
||||
}
|
||||
|
||||
private static bool HasSummary(CampaignLogListEntry entry)
|
||||
{
|
||||
return (entry.EventBadges?.Length ?? 0) > 0 || !string.IsNullOrWhiteSpace(entry.SummaryText);
|
||||
}
|
||||
|
||||
private static EventBadgeView? ToEventBadgeView(string code)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"w6" => new("Wild 6", "positive"),
|
||||
"w1" => new("Wild 1", "danger"),
|
||||
"n20" => new("Nat 20", "positive"),
|
||||
"n1" => new("Nat 1", "danger"),
|
||||
"rf" => new("Fumble", "danger"),
|
||||
"r100" => new("100", "rare"),
|
||||
"r66" => new("66", "rare"),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string LogEntryCssClass(CampaignLogListEntry entry, bool isExpanded, bool isFresh)
|
||||
{
|
||||
var classes = new List<string> { entry.VisibilityStyle };
|
||||
if (isExpanded)
|
||||
classes.Add("expanded");
|
||||
|
||||
if (isFresh)
|
||||
classes.Add("fresh");
|
||||
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JS { get; set; } = null!;
|
||||
|
||||
@@ -97,105 +188,6 @@ public partial class CampaignLogPanel
|
||||
[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 ?? [])
|
||||
.Select(ToEventBadgeView)
|
||||
.Where(badge => badge is not null)
|
||||
.Cast<EventBadgeView>()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool HasSummary(CampaignLogListEntry entry)
|
||||
{
|
||||
return (entry.EventBadges?.Length ?? 0) > 0 || !string.IsNullOrWhiteSpace(entry.SummaryText);
|
||||
}
|
||||
|
||||
private static EventBadgeView? ToEventBadgeView(string code)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"w6" => new EventBadgeView("Wild 6", "positive"),
|
||||
"w1" => new EventBadgeView("Wild 1", "danger"),
|
||||
"n20" => new EventBadgeView("Nat 20", "positive"),
|
||||
"n1" => new EventBadgeView("Nat 1", "danger"),
|
||||
"rf" => new EventBadgeView("Fumble", "danger"),
|
||||
"r100" => new EventBadgeView("100", "rare"),
|
||||
"r66" => new EventBadgeView("66", "rare"),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string LogEntryCssClass(CampaignLogListEntry entry, bool isExpanded, bool isFresh)
|
||||
{
|
||||
var classes = new List<string> { entry.VisibilityStyle };
|
||||
if (isExpanded)
|
||||
classes.Add("expanded");
|
||||
|
||||
if (isFresh)
|
||||
classes.Add("fresh");
|
||||
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -203,24 +195,27 @@ public partial class CampaignLogPanel
|
||||
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.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"
|
||||
_ => "Enter a roll expression"
|
||||
};
|
||||
private string CustomRollStatusText => SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName)
|
||||
? $"For {SelectedCharacterName} • {RollVisibilityLabel}"
|
||||
: "Select a character to enable";
|
||||
|
||||
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.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."
|
||||
_ => "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;
|
||||
@@ -231,4 +226,4 @@ public partial class CampaignLogPanel
|
||||
CustomRollState.ResetValidation();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,4 +129,4 @@
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -115,4 +115,4 @@ public partial class CampaignManagementPanel
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<CharacterSummary> DeleteCharacterRequested { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -48,4 +48,4 @@
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -54,9 +54,7 @@ public partial class CharacterFormModal
|
||||
character = await ApiClient.RequestAsync<CharacterSummary>("PUT", $"/api/characters/{EditingCharacterId.Value}", new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId, ownerUsername));
|
||||
}
|
||||
else
|
||||
{
|
||||
character = await ApiClient.RequestAsync<CharacterSummary>("POST", "/api/characters", new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId!.Value));
|
||||
}
|
||||
|
||||
await CharacterSaved.InvokeAsync(character.CampaignId);
|
||||
}
|
||||
@@ -121,4 +119,4 @@ public partial class CharacterFormModal
|
||||
|
||||
[Parameter]
|
||||
public EventCallback CancelRequested { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,12 @@
|
||||
<span aria-hidden="true" class="emoji">✏️</span>
|
||||
<span class="sr-only">Edit character</span>
|
||||
</button>
|
||||
<h3 class="skills-heading">@SelectedCharacter.Name <span
|
||||
class="muted">| Owner: @OwnerLabel(SelectedCharacter.OwnerUserId) | Campaign: @SelectedCampaign.Name</span>
|
||||
<h3 class="skills-heading">
|
||||
@SelectedCharacter.Name
|
||||
<span
|
||||
class="muted">
|
||||
| Owner: @OwnerLabel(SelectedCharacter.OwnerUserId) | Campaign: @SelectedCampaign.Name
|
||||
</span>
|
||||
</h3>
|
||||
<div class="skill-filter-wrap">
|
||||
<label class="sr-only" for="skill-filter-input">Filter skills</label>
|
||||
@@ -130,6 +134,7 @@
|
||||
</button>
|
||||
</article>
|
||||
}
|
||||
|
||||
<div class="character-panel-fill" aria-hidden="true"></div>
|
||||
}
|
||||
</section>
|
||||
@@ -242,4 +247,4 @@
|
||||
AvailableSkillGroups="SelectedCharacterSkillGroups"
|
||||
IsMutating="IsMutating"
|
||||
SkillSaved="OnSkillUpdatedAsync"
|
||||
CancelRequested="CloseSkillModals"/>
|
||||
CancelRequested="CloseSkillModals"/>
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
@@ -10,9 +9,7 @@ public partial class CharacterPanel
|
||||
{
|
||||
private void OpenCreateSkillModal(Guid? skillGroupId = null)
|
||||
{
|
||||
var selectedGroup = skillGroupId.HasValue
|
||||
? SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId.Value)
|
||||
: null;
|
||||
var selectedGroup = skillGroupId.HasValue ? SelectedCharacterSkillGroups.FirstOrDefault(group => group.Id == skillGroupId.Value) : null;
|
||||
|
||||
CreateSkillInitialModel = new()
|
||||
{
|
||||
@@ -156,9 +153,7 @@ public partial class CharacterPanel
|
||||
SkillGroupState.Errors["fumbleRange"] = "Open-ended Rolemaster groups require a fumble range.";
|
||||
}
|
||||
else
|
||||
{
|
||||
SkillGroupState.Model.FumbleRange = null;
|
||||
}
|
||||
|
||||
if (!IsD6Ruleset)
|
||||
{
|
||||
@@ -179,15 +174,7 @@ public partial class CharacterPanel
|
||||
try
|
||||
{
|
||||
var selectedCharacterId = SelectedCharacterId!.Value;
|
||||
var createdGroup = await ApiClient.RequestAsync<SkillGroupSummary>(
|
||||
"POST",
|
||||
$"/api/characters/{selectedCharacterId}/skill-groups",
|
||||
new CreateSkillGroupRequest(
|
||||
SkillGroupState.Model.Name.Trim(),
|
||||
SkillGroupState.Model.DiceRollDefinition.Trim(),
|
||||
SkillGroupState.Model.WildDice,
|
||||
SkillGroupState.Model.AllowFumble,
|
||||
SkillGroupState.Model.FumbleRange));
|
||||
var createdGroup = await ApiClient.RequestAsync<SkillGroupSummary>("POST", $"/api/characters/{selectedCharacterId}/skill-groups", new CreateSkillGroupRequest(SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange));
|
||||
CloseSkillGroupModals();
|
||||
await SkillGroupCreated.InvokeAsync(createdGroup.Id);
|
||||
}
|
||||
@@ -220,9 +207,7 @@ public partial class CharacterPanel
|
||||
SkillGroupState.Errors["fumbleRange"] = "Open-ended Rolemaster groups require a fumble range.";
|
||||
}
|
||||
else
|
||||
{
|
||||
SkillGroupState.Model.FumbleRange = null;
|
||||
}
|
||||
|
||||
if (!IsD6Ruleset)
|
||||
{
|
||||
@@ -243,15 +228,7 @@ public partial class CharacterPanel
|
||||
try
|
||||
{
|
||||
var editingSkillGroupId = EditingSkillGroupId!.Value;
|
||||
var updatedGroup = await ApiClient.RequestAsync<SkillGroupSummary>(
|
||||
"PUT",
|
||||
$"/api/skill-groups/{editingSkillGroupId}",
|
||||
new UpdateSkillGroupRequest(
|
||||
SkillGroupState.Model.Name.Trim(),
|
||||
SkillGroupState.Model.DiceRollDefinition.Trim(),
|
||||
SkillGroupState.Model.WildDice,
|
||||
SkillGroupState.Model.AllowFumble,
|
||||
SkillGroupState.Model.FumbleRange));
|
||||
var updatedGroup = await ApiClient.RequestAsync<SkillGroupSummary>("PUT", $"/api/skill-groups/{editingSkillGroupId}", new UpdateSkillGroupRequest(SkillGroupState.Model.Name.Trim(), SkillGroupState.Model.DiceRollDefinition.Trim(), SkillGroupState.Model.WildDice, SkillGroupState.Model.AllowFumble, SkillGroupState.Model.FumbleRange));
|
||||
CloseSkillGroupModals();
|
||||
await SkillGroupUpdated.InvokeAsync(updatedGroup.Id);
|
||||
}
|
||||
@@ -297,8 +274,7 @@ public partial class CharacterPanel
|
||||
return true;
|
||||
|
||||
var filter = SkillFilterText.Trim();
|
||||
return skill.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) ||
|
||||
skill.DiceRollDefinition.Contains(filter, StringComparison.OrdinalIgnoreCase);
|
||||
return skill.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) || skill.DiceRollDefinition.Contains(filter, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string InitialsFor(string value)
|
||||
@@ -340,9 +316,8 @@ public partial class CharacterPanel
|
||||
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(SelectedCampaignRulesetId);
|
||||
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(SelectedCampaignRulesetId);
|
||||
private bool IsSkillGroupRolemasterOpenEnded => RulesetFormHelpers.IsRolemasterOpenEndedExpression(SkillGroupState.Model.DiceRollDefinition);
|
||||
private string SkillGroupExpressionHelpText => IsRolemasterRuleset
|
||||
? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed."
|
||||
: "Enter the default expression for skills created in this group.";
|
||||
|
||||
private string SkillGroupExpressionHelpText => IsRolemasterRuleset ? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed." : "Enter the default expression for skills created in this group.";
|
||||
|
||||
private bool ShowCreateSkillModal { get; set; }
|
||||
private bool ShowEditSkillModal { get; set; }
|
||||
@@ -432,4 +407,4 @@ public partial class CharacterPanel
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> RollRequested { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,4 @@
|
||||
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieDisplay(die)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ public partial class RollDiceStrip
|
||||
|
||||
return die.Kind switch
|
||||
{
|
||||
_ => RollDieGlyph(die.Roll)
|
||||
_ => RollDieGlyph(die.Roll)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,10 +81,7 @@ public partial class RollDiceStrip
|
||||
|
||||
private static bool IsRolemasterDie(RollDieResult die)
|
||||
{
|
||||
return die.Kind is RollDieKinds.RolemasterStandard or
|
||||
RollDieKinds.RolemasterOpenEndedInitial or
|
||||
RollDieKinds.RolemasterOpenEndedHigh or
|
||||
RollDieKinds.RolemasterOpenEndedLowSubtract;
|
||||
return die.Kind is RollDieKinds.RolemasterStandard or RollDieKinds.RolemasterOpenEndedInitial or RollDieKinds.RolemasterOpenEndedHigh or RollDieKinds.RolemasterOpenEndedLowSubtract;
|
||||
}
|
||||
|
||||
private static string RollDieTitle(RollDieResult die)
|
||||
@@ -132,4 +129,4 @@ public partial class RollDiceStrip
|
||||
|
||||
[Parameter]
|
||||
public string AriaLabel { get; set; } = "Rolled dice";
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,7 @@ internal static class RulesetFormHelpers
|
||||
public static bool IsRolemasterOpenEndedExpression(string? expression)
|
||||
{
|
||||
var parseResult = TryParseRolemasterExpression(expression);
|
||||
return parseResult.Succeeded &&
|
||||
parseResult.Value is not null &&
|
||||
parseResult.Value.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile;
|
||||
return parseResult.Succeeded && parseResult.Value is not null && parseResult.Value.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile;
|
||||
}
|
||||
|
||||
public static string DescribeRolemasterExpression(string expression, int? fumbleRange)
|
||||
@@ -40,10 +38,8 @@ internal static class RulesetFormHelpers
|
||||
|
||||
return parseResult.Value.Kind switch
|
||||
{
|
||||
DiceExpressionKind.RolemasterOpenEndedPercentile => fumbleRange.HasValue
|
||||
? $"Open-ended percentile: {parseResult.Value.Canonical}, fumble <= {fumbleRange.Value}"
|
||||
: $"Open-ended percentile: {parseResult.Value.Canonical}",
|
||||
_ => $"Rolemaster: {parseResult.Value.Canonical}"
|
||||
DiceExpressionKind.RolemasterOpenEndedPercentile => fumbleRange.HasValue ? $"Open-ended percentile: {parseResult.Value.Canonical}, fumble <= {fumbleRange.Value}" : $"Open-ended percentile: {parseResult.Value.Canonical}",
|
||||
_ => $"Rolemaster: {parseResult.Value.Canonical}"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,4 +55,4 @@ internal static class RulesetFormHelpers
|
||||
|
||||
return DiceRules.ParseExpression(RulesetKind.Rolemaster, expression);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,4 +65,4 @@
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages.HomeControls;
|
||||
@@ -54,9 +53,7 @@ public partial class SkillFormModal
|
||||
FormState.Errors["fumbleRange"] = "Open-ended Rolemaster skills require a fumble range.";
|
||||
}
|
||||
else
|
||||
{
|
||||
FormState.Model.FumbleRange = null;
|
||||
}
|
||||
|
||||
if (!IsD6Ruleset)
|
||||
{
|
||||
@@ -84,9 +81,7 @@ public partial class SkillFormModal
|
||||
{
|
||||
SkillSummary skill;
|
||||
if (EditingSkillId.HasValue)
|
||||
{
|
||||
skill = await ApiClient.RequestAsync<SkillSummary>("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble, skillGroupId, FormState.Model.FumbleRange));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!SelectedCharacterId.HasValue)
|
||||
@@ -117,13 +112,6 @@ public partial class SkillFormModal
|
||||
NormalizeRolemasterFumbleRange();
|
||||
}
|
||||
|
||||
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(RulesetId);
|
||||
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(RulesetId);
|
||||
private bool IsRolemasterOpenEndedSelected => RulesetFormHelpers.IsRolemasterOpenEndedExpression(FormState.Model.DiceRollDefinition);
|
||||
private string ExpressionHelpText => IsRolemasterRuleset
|
||||
? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed."
|
||||
: "Enter the dice expression used for this skill.";
|
||||
|
||||
private void SynchronizeRulesetSpecificFields()
|
||||
{
|
||||
if (!IsRolemasterRuleset)
|
||||
@@ -149,6 +137,12 @@ public partial class SkillFormModal
|
||||
FormState.Model.FumbleRange = null;
|
||||
}
|
||||
|
||||
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(RulesetId);
|
||||
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(RulesetId);
|
||||
private bool IsRolemasterOpenEndedSelected => RulesetFormHelpers.IsRolemasterOpenEndedExpression(FormState.Model.DiceRollDefinition);
|
||||
|
||||
private string ExpressionHelpText => IsRolemasterRuleset ? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed." : "Enter the dice expression used for this skill.";
|
||||
|
||||
[Inject]
|
||||
private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||
|
||||
@@ -214,4 +208,4 @@ public partial class SkillFormModal
|
||||
|
||||
[Parameter]
|
||||
public EventCallback CancelRequested { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -77,4 +77,4 @@
|
||||
<span>Add skill</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,4 +57,4 @@ public partial class SkillGroupBlock
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Guid> DeleteGroupRequested { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -73,13 +73,15 @@
|
||||
GetRollDetailError="Play.GetRollDetailError"
|
||||
CustomRollCreated="Play.OnCustomRollCreatedAsync"
|
||||
ErrorOccurred="Play.OnCampaignLogPanelErrorAsync"/>
|
||||
</main>
|
||||
</main>
|
||||
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
|
||||
<button type="button" class="switch @(State.MobilePanel == "character" ? "active" : string.Empty)"
|
||||
@onclick='() => Scope.SetMobilePanelAsync("character")'>Character
|
||||
@onclick='() => Scope.SetMobilePanelAsync("character")'>
|
||||
Character
|
||||
</button>
|
||||
<button type="button" class="switch @(State.MobilePanel == "log" ? "active" : string.Empty)"
|
||||
@onclick='() => Scope.SetMobilePanelAsync("log")'>Log
|
||||
@onclick='() => Scope.SetMobilePanelAsync("log")'>
|
||||
Log
|
||||
</button>
|
||||
</nav>
|
||||
}
|
||||
@@ -214,4 +216,4 @@
|
||||
AllowOwnerEdit="State.CanEditCharacterOwner"
|
||||
AvailableUsernames="State.KnownUsernames"
|
||||
CharacterSaved="Campaigns.OnCharacterUpdatedAsync"
|
||||
CancelRequested="Campaigns.CloseCharacterModals"/>
|
||||
CancelRequested="Campaigns.CloseCharacterModals"/>
|
||||
@@ -20,10 +20,16 @@ public partial class Workspace : IAsyncDisposable
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public Task OnStateEventReceived(CampaignStateSnapshot state) => Live.OnStateEventReceivedAsync(state);
|
||||
public Task OnStateEventReceived(CampaignStateSnapshot state)
|
||||
{
|
||||
return Live.OnStateEventReceivedAsync(state);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public Task OnConnectionStateChanged(string state) => Live.OnConnectionStateChangedAsync(state);
|
||||
public Task OnConnectionStateChanged(string state)
|
||||
{
|
||||
return Live.OnConnectionStateChangedAsync(state);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
@@ -31,13 +37,25 @@ public partial class Workspace : IAsyncDisposable
|
||||
DotNetRef?.Dispose();
|
||||
}
|
||||
|
||||
private bool CanEditCharacter(CharacterSummary character) => Campaigns.CanEditCharacter(character);
|
||||
private bool CanEditCharacter(CharacterSummary character)
|
||||
{
|
||||
return Campaigns.CanEditCharacter(character);
|
||||
}
|
||||
|
||||
private void ClearAuthenticatedState() => Session.ClearAuthenticatedState();
|
||||
private void ClearAuthenticatedState()
|
||||
{
|
||||
Session.ClearAuthenticatedState();
|
||||
}
|
||||
|
||||
private Task EnsureAdminUsersLoadedAsync() => Admin.EnsureAdminUsersLoadedAsync();
|
||||
private Task EnsureAdminUsersLoadedAsync()
|
||||
{
|
||||
return Admin.EnsureAdminUsersLoadedAsync();
|
||||
}
|
||||
|
||||
private Task StopStateEventsAsync() => Live.StopStateEventsAsync();
|
||||
private Task StopStateEventsAsync()
|
||||
{
|
||||
return Live.StopStateEventsAsync();
|
||||
}
|
||||
|
||||
private async Task StartStateEventsCoreAsync(Guid campaignId)
|
||||
{
|
||||
@@ -86,76 +104,19 @@ public partial class Workspace : IAsyncDisposable
|
||||
|
||||
private WorkspaceState State { get; } = new();
|
||||
|
||||
private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(
|
||||
State,
|
||||
Feedback,
|
||||
JS,
|
||||
WorkspaceQuery,
|
||||
Play.EnsureSelectedCharacterActiveAsync,
|
||||
Play.RefreshSelectedCharacterSheetAsync,
|
||||
Play.RefreshCampaignLogAsync,
|
||||
Play.ResetCampaignLogDetailState,
|
||||
Play.ResetCampaignStateTracking,
|
||||
ClearAuthenticatedState,
|
||||
StopStateEventsAsync,
|
||||
message => LoggedOut.InvokeAsync(message));
|
||||
private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync, Play.RefreshCampaignLogAsync, Play.ResetCampaignLogDetailState, Play.ResetCampaignStateTracking, ClearAuthenticatedState, StopStateEventsAsync, message => LoggedOut.InvokeAsync(message));
|
||||
|
||||
private WorkspaceLiveStateController Live => m_Live ??= new(
|
||||
State,
|
||||
Feedback,
|
||||
StartStateEventsCoreAsync,
|
||||
StopStateEventsCoreAsync,
|
||||
Scope.RefreshCampaignRosterAsync,
|
||||
Play.RefreshSelectedCharacterSheetAsync,
|
||||
Play.RefreshCampaignLogAsync,
|
||||
() => InvokeAsync(StateHasChanged));
|
||||
private WorkspaceLiveStateController Live => m_Live ??= new(State, Feedback, StartStateEventsCoreAsync, StopStateEventsCoreAsync, Scope.RefreshCampaignRosterAsync, Play.RefreshSelectedCharacterSheetAsync, Play.RefreshCampaignLogAsync, () => InvokeAsync(StateHasChanged));
|
||||
|
||||
private WorkspacePlayCoordinator Play => m_Play ??= new(
|
||||
State,
|
||||
Feedback,
|
||||
ApiClient,
|
||||
WorkspaceQuery,
|
||||
CanEditCharacter,
|
||||
() => InvokeAsync(StateHasChanged));
|
||||
private WorkspacePlayCoordinator Play => m_Play ??= new(State, Feedback, ApiClient, WorkspaceQuery, CanEditCharacter, () => InvokeAsync(StateHasChanged));
|
||||
|
||||
private WorkspaceCampaignCoordinator Campaigns => m_Campaigns ??= new(
|
||||
State,
|
||||
Feedback,
|
||||
JS,
|
||||
ApiClient,
|
||||
Session.LoadKnownUsernamesAsync,
|
||||
Scope.ReloadCampaignsAsync,
|
||||
Scope.ReloadCharacterCampaignOptionsAsync,
|
||||
Scope.RefreshCampaignScopeAsync,
|
||||
Live.SyncStateEventsAsync);
|
||||
private WorkspaceCampaignCoordinator Campaigns => m_Campaigns ??= new(State, Feedback, JS, ApiClient, Session.LoadKnownUsernamesAsync, Scope.ReloadCampaignsAsync, Scope.ReloadCharacterCampaignOptionsAsync, Scope.RefreshCampaignScopeAsync, Live.SyncStateEventsAsync);
|
||||
|
||||
private WorkspaceAdminCoordinator Admin => m_Admin ??= new(
|
||||
State,
|
||||
Feedback,
|
||||
JS,
|
||||
ApiClient,
|
||||
WorkspaceQuery,
|
||||
ClearAuthenticatedState,
|
||||
StopStateEventsAsync,
|
||||
message => LoggedOut.InvokeAsync(message));
|
||||
private WorkspaceAdminCoordinator Admin => m_Admin ??= new(State, Feedback, JS, ApiClient, WorkspaceQuery, ClearAuthenticatedState, StopStateEventsAsync, message => LoggedOut.InvokeAsync(message));
|
||||
|
||||
private WorkspaceFeedbackService Feedback => m_Feedback ??= new(State, () => InvokeAsync(StateHasChanged));
|
||||
|
||||
private WorkspaceSessionCoordinator Session => m_Session ??= new(
|
||||
State,
|
||||
Feedback,
|
||||
JS,
|
||||
ApiClient,
|
||||
WorkspaceQuery,
|
||||
Scope.ReloadCampaignsAsync,
|
||||
Scope.ReloadCharacterCampaignOptionsAsync,
|
||||
Scope.RefreshCampaignScopeAsync,
|
||||
Live.SyncStateEventsAsync,
|
||||
Live.StopStateEventsAsync,
|
||||
EnsureAdminUsersLoadedAsync,
|
||||
Play.ResetCampaignLogDetailState,
|
||||
() => InvokeAsync(StateHasChanged),
|
||||
message => LoggedOut.InvokeAsync(message));
|
||||
private WorkspaceSessionCoordinator Session => m_Session ??= new(State, Feedback, JS, ApiClient, WorkspaceQuery, Scope.ReloadCampaignsAsync, Scope.ReloadCharacterCampaignOptionsAsync, Scope.RefreshCampaignScopeAsync, Live.SyncStateEventsAsync, Live.StopStateEventsAsync, EnsureAdminUsersLoadedAsync, Play.ResetCampaignLogDetailState, () => InvokeAsync(StateHasChanged), message => LoggedOut.InvokeAsync(message));
|
||||
|
||||
private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems
|
||||
{
|
||||
@@ -163,12 +124,27 @@ public partial class Workspace : IAsyncDisposable
|
||||
{
|
||||
var items = new List<AppHeaderMenuItem>
|
||||
{
|
||||
new() { Label = "Play", IsActive = State.IsPlayScreen, OnSelected = () => Session.SwitchScreenAsync("play") },
|
||||
new() { Label = "Campaign Management", IsActive = State.IsManagementScreen, OnSelected = () => Session.SwitchScreenAsync("management") }
|
||||
new()
|
||||
{
|
||||
Label = "Play",
|
||||
IsActive = State.IsPlayScreen,
|
||||
OnSelected = () => Session.SwitchScreenAsync("play")
|
||||
},
|
||||
new()
|
||||
{
|
||||
Label = "Campaign Management",
|
||||
IsActive = State.IsManagementScreen,
|
||||
OnSelected = () => Session.SwitchScreenAsync("management")
|
||||
}
|
||||
};
|
||||
|
||||
if (State.IsCurrentUserAdmin)
|
||||
items.Add(new AppHeaderMenuItem { Label = "Admin", IsActive = State.IsAdminScreen, OnSelected = () => Session.SwitchScreenAsync(ScreenAdmin) });
|
||||
items.Add(new()
|
||||
{
|
||||
Label = "Admin",
|
||||
IsActive = State.IsAdminScreen,
|
||||
OnSelected = () => Session.SwitchScreenAsync(ScreenAdmin)
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -178,12 +154,12 @@ public partial class Workspace : IAsyncDisposable
|
||||
private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
|
||||
|
||||
private const string ScreenAdmin = "admin";
|
||||
|
||||
private WorkspaceCampaignScopeCoordinator? m_Scope;
|
||||
private WorkspaceAdminCoordinator? m_Admin;
|
||||
private WorkspaceCampaignCoordinator? m_Campaigns;
|
||||
private WorkspaceFeedbackService? m_Feedback;
|
||||
private WorkspaceLiveStateController? m_Live;
|
||||
private WorkspacePlayCoordinator? m_Play;
|
||||
private WorkspaceCampaignCoordinator? m_Campaigns;
|
||||
private WorkspaceAdminCoordinator? m_Admin;
|
||||
private WorkspaceFeedbackService? m_Feedback;
|
||||
|
||||
private WorkspaceCampaignScopeCoordinator? m_Scope;
|
||||
private WorkspaceSessionCoordinator? m_Session;
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,7 @@ namespace RpgRoller.Components.Pages;
|
||||
[ExcludeFromCodeCoverage]
|
||||
public sealed class WorkspaceAdminCoordinator
|
||||
{
|
||||
public WorkspaceAdminCoordinator(
|
||||
WorkspaceState state,
|
||||
WorkspaceFeedbackService feedback,
|
||||
IJSRuntime js,
|
||||
RpgRollerApiClient apiClient,
|
||||
WorkspaceQueryService workspaceQuery,
|
||||
Action clearAuthenticatedState,
|
||||
Func<Task> stopStateEventsAsync,
|
||||
Func<string?, Task> onLoggedOutAsync)
|
||||
public WorkspaceAdminCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Action clearAuthenticatedState, Func<Task> stopStateEventsAsync, Func<string?, Task> onLoggedOutAsync)
|
||||
{
|
||||
m_State = state;
|
||||
m_Feedback = feedback;
|
||||
@@ -63,10 +55,7 @@ public sealed class WorkspaceAdminCoordinator
|
||||
try
|
||||
{
|
||||
IReadOnlyList<string> roles = HasAdminRole(user) ? Array.Empty<string>() : [UserRoles.Admin];
|
||||
_ = await m_ApiClient.RequestAsync<AdminUserSummary>(
|
||||
"PUT",
|
||||
$"/api/admin/users/{user.Id}/roles",
|
||||
new UpdateUserRolesRequest(roles));
|
||||
_ = await m_ApiClient.RequestAsync<AdminUserSummary>("PUT", $"/api/admin/users/{user.Id}/roles", new UpdateUserRolesRequest(roles));
|
||||
|
||||
await ReloadAdminUsersAsync();
|
||||
m_Feedback.SetStatus("User roles updated.", false);
|
||||
@@ -109,9 +98,7 @@ public sealed class WorkspaceAdminCoordinator
|
||||
|
||||
private async Task ReloadAdminUsersAsync()
|
||||
{
|
||||
m_State.AdminUsers = (await m_WorkspaceQuery.GetAdminUsersAsync())
|
||||
.OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
m_State.AdminUsers = (await m_WorkspaceQuery.GetAdminUsersAsync()).OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
m_State.HasLoadedAdminUsers = true;
|
||||
}
|
||||
@@ -129,4 +116,4 @@ public sealed class WorkspaceAdminCoordinator
|
||||
private readonly WorkspaceState m_State;
|
||||
private readonly Func<Task> m_StopStateEventsAsync;
|
||||
private readonly WorkspaceQueryService m_WorkspaceQuery;
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,7 @@ namespace RpgRoller.Components.Pages;
|
||||
[ExcludeFromCodeCoverage]
|
||||
public sealed class WorkspaceCampaignCoordinator
|
||||
{
|
||||
public WorkspaceCampaignCoordinator(
|
||||
WorkspaceState state,
|
||||
WorkspaceFeedbackService feedback,
|
||||
IJSRuntime js,
|
||||
RpgRollerApiClient apiClient,
|
||||
Func<Task> loadKnownUsernamesAsync,
|
||||
Func<Guid?, Task> reloadCampaignsAsync,
|
||||
Func<Task> reloadCharacterCampaignOptionsAsync,
|
||||
Func<Task> refreshCampaignScopeAsync,
|
||||
Func<Task> syncStateEventsAsync)
|
||||
public WorkspaceCampaignCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, Func<Task> loadKnownUsernamesAsync, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> syncStateEventsAsync)
|
||||
{
|
||||
m_State = state;
|
||||
m_Feedback = feedback;
|
||||
@@ -179,15 +170,15 @@ public sealed class WorkspaceCampaignCoordinator
|
||||
return m_State.User is not null && (character.OwnerUserId == m_State.User.Id || m_State.IsCurrentUserAdmin);
|
||||
}
|
||||
|
||||
private const string CampaignSessionKey = "campaign";
|
||||
|
||||
private readonly RpgRollerApiClient m_ApiClient;
|
||||
private readonly WorkspaceFeedbackService m_Feedback;
|
||||
private readonly IJSRuntime m_JS;
|
||||
private readonly Func<Task> m_LoadKnownUsernamesAsync;
|
||||
private readonly Func<Task> m_ReloadCharacterCampaignOptionsAsync;
|
||||
private readonly Func<Guid?, Task> m_ReloadCampaignsAsync;
|
||||
private readonly Func<Task> m_RefreshCampaignScopeAsync;
|
||||
private readonly Func<Guid?, Task> m_ReloadCampaignsAsync;
|
||||
private readonly Func<Task> m_ReloadCharacterCampaignOptionsAsync;
|
||||
private readonly WorkspaceState m_State;
|
||||
private readonly Func<Task> m_SyncStateEventsAsync;
|
||||
|
||||
private const string CampaignSessionKey = "campaign";
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,12 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.JSInterop;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public sealed class WorkspaceCampaignScopeCoordinator
|
||||
{
|
||||
public WorkspaceCampaignScopeCoordinator(
|
||||
WorkspaceState state,
|
||||
WorkspaceFeedbackService feedback,
|
||||
IJSRuntime js,
|
||||
WorkspaceQueryService workspaceQuery,
|
||||
Func<Task> ensureSelectedCharacterActiveAsync,
|
||||
Func<Task> refreshSelectedCharacterSheetAsync,
|
||||
Func<Guid?, Task> refreshCampaignLogAsync,
|
||||
Action resetCampaignLogDetailState,
|
||||
Action resetCampaignStateTracking,
|
||||
Action clearAuthenticatedState,
|
||||
Func<Task> stopStateEventsAsync,
|
||||
Func<string?, Task> onLoggedOutAsync)
|
||||
public WorkspaceCampaignScopeCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, WorkspaceQueryService workspaceQuery, Func<Task> ensureSelectedCharacterActiveAsync, Func<Task> refreshSelectedCharacterSheetAsync, Func<Guid?, Task> refreshCampaignLogAsync, Action resetCampaignLogDetailState, Action resetCampaignStateTracking, Action clearAuthenticatedState, Func<Task> stopStateEventsAsync, Func<string?, Task> onLoggedOutAsync)
|
||||
{
|
||||
m_State = state;
|
||||
m_Feedback = feedback;
|
||||
@@ -147,6 +134,9 @@ public sealed class WorkspaceCampaignScopeCoordinator
|
||||
m_State.SelectedCharacterId = m_State.SelectedCampaign.Characters[0].Id;
|
||||
}
|
||||
|
||||
private const string CampaignSessionKey = "campaign";
|
||||
private const string MobilePanelSessionKey = "play-panel";
|
||||
|
||||
private readonly Action m_ClearAuthenticatedState;
|
||||
private readonly Func<Task> m_EnsureSelectedCharacterActiveAsync;
|
||||
private readonly WorkspaceFeedbackService m_Feedback;
|
||||
@@ -159,7 +149,4 @@ public sealed class WorkspaceCampaignScopeCoordinator
|
||||
private readonly WorkspaceState m_State;
|
||||
private readonly Func<Task> m_StopStateEventsAsync;
|
||||
private readonly WorkspaceQueryService m_WorkspaceQuery;
|
||||
|
||||
private const string CampaignSessionKey = "campaign";
|
||||
private const string MobilePanelSessionKey = "play-panel";
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ public sealed class WorkspaceFeedbackService
|
||||
private void AddToast(string message, bool isError)
|
||||
{
|
||||
var toastId = Guid.NewGuid();
|
||||
m_State.Toasts.Add(new WorkspaceToast(toastId, message, isError));
|
||||
m_State.Toasts.Add(new(toastId, message, isError));
|
||||
_ = DismissToastLaterAsync(toastId);
|
||||
}
|
||||
|
||||
@@ -47,8 +47,8 @@ public sealed class WorkspaceFeedbackService
|
||||
}
|
||||
}
|
||||
|
||||
private const int ToastDurationMs = 3200;
|
||||
|
||||
private readonly Func<Task> m_RequestRefreshAsync;
|
||||
private readonly WorkspaceState m_State;
|
||||
|
||||
private const int ToastDurationMs = 3200;
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,7 @@ namespace RpgRoller.Components.Pages;
|
||||
[ExcludeFromCodeCoverage]
|
||||
public sealed class WorkspaceLiveStateController
|
||||
{
|
||||
public WorkspaceLiveStateController(
|
||||
WorkspaceState state,
|
||||
WorkspaceFeedbackService feedback,
|
||||
Func<Guid, Task> startStateEventsAsync,
|
||||
Func<Task> stopStateEventsCoreAsync,
|
||||
Func<Task> refreshCampaignRosterAsync,
|
||||
Func<Task> refreshSelectedCharacterSheetAsync,
|
||||
Func<Guid?, Task> refreshCampaignLogAsync,
|
||||
Func<Task> requestRefreshAsync)
|
||||
public WorkspaceLiveStateController(WorkspaceState state, WorkspaceFeedbackService feedback, Func<Guid, Task> startStateEventsAsync, Func<Task> stopStateEventsCoreAsync, Func<Task> refreshCampaignRosterAsync, Func<Task> refreshSelectedCharacterSheetAsync, Func<Guid?, Task> refreshCampaignLogAsync, Func<Task> requestRefreshAsync)
|
||||
{
|
||||
m_State = state;
|
||||
m_Feedback = feedback;
|
||||
@@ -53,9 +45,7 @@ public sealed class WorkspaceLiveStateController
|
||||
await m_RefreshCampaignRosterAsync();
|
||||
|
||||
var selectedCharacterChanged = previousSelectedCharacterId != m_State.SelectedCharacterId;
|
||||
var selectedCharacterVersionChanged = m_State.IsPlayScreen &&
|
||||
!selectedCharacterChanged &&
|
||||
GetCharacterVersion(state, m_State.SelectedCharacterId) != previousSelectedCharacterVersion;
|
||||
var selectedCharacterVersionChanged = m_State.IsPlayScreen && !selectedCharacterChanged && GetCharacterVersion(state, m_State.SelectedCharacterId) != previousSelectedCharacterVersion;
|
||||
|
||||
if (m_State.IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged))
|
||||
await m_RefreshSelectedCharacterSheetAsync();
|
||||
@@ -116,9 +106,7 @@ public sealed class WorkspaceLiveStateController
|
||||
if (!characterId.HasValue)
|
||||
return 0;
|
||||
|
||||
return snapshot.CharacterVersions
|
||||
.FirstOrDefault(version => version.CharacterId == characterId.Value)
|
||||
?.Version ?? 0;
|
||||
return snapshot.CharacterVersions.FirstOrDefault(version => version.CharacterId == characterId.Value)?.Version ?? 0;
|
||||
}
|
||||
|
||||
private readonly WorkspaceFeedbackService m_Feedback;
|
||||
@@ -129,4 +117,4 @@ public sealed class WorkspaceLiveStateController
|
||||
private readonly Func<Guid, Task> m_StartStateEventsAsync;
|
||||
private readonly WorkspaceState m_State;
|
||||
private readonly Func<Task> m_StopStateEventsCoreAsync;
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,7 @@ namespace RpgRoller.Components.Pages;
|
||||
[ExcludeFromCodeCoverage]
|
||||
public sealed class WorkspacePlayCoordinator
|
||||
{
|
||||
public WorkspacePlayCoordinator(
|
||||
WorkspaceState state,
|
||||
WorkspaceFeedbackService feedback,
|
||||
RpgRollerApiClient apiClient,
|
||||
WorkspaceQueryService workspaceQuery,
|
||||
Func<CharacterSummary, bool> canEditCharacter,
|
||||
Func<Task> requestRefreshAsync)
|
||||
public WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<CharacterSummary, bool> canEditCharacter, Func<Task> requestRefreshAsync)
|
||||
{
|
||||
m_State = state;
|
||||
m_Feedback = feedback;
|
||||
@@ -36,9 +30,7 @@ public sealed class WorkspacePlayCoordinator
|
||||
var page = await m_WorkspaceQuery.GetCampaignLogPageAsync(m_State.SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize);
|
||||
Guid? newestRollId = null;
|
||||
if (!afterRollId.HasValue || page.ResetRequired)
|
||||
{
|
||||
m_State.CampaignLog = page.Entries.ToList();
|
||||
}
|
||||
else if (page.Entries.Length > 0)
|
||||
{
|
||||
m_State.CampaignLog.AddRange(page.Entries);
|
||||
@@ -47,14 +39,8 @@ public sealed class WorkspacePlayCoordinator
|
||||
}
|
||||
|
||||
var shouldAutoExpandNewest = afterRollId.HasValue && page.Entries.Length > 0;
|
||||
if (!shouldAutoExpandNewest &&
|
||||
!afterRollId.HasValue &&
|
||||
m_State.CurrentCampaignState is not null &&
|
||||
previousLogCount == 0 &&
|
||||
page.Entries.Length > 0)
|
||||
{
|
||||
if (!shouldAutoExpandNewest && !afterRollId.HasValue && m_State.CurrentCampaignState is not null && previousLogCount == 0 && page.Entries.Length > 0)
|
||||
shouldAutoExpandNewest = true;
|
||||
}
|
||||
|
||||
if (shouldAutoExpandNewest)
|
||||
{
|
||||
@@ -63,9 +49,7 @@ public sealed class WorkspacePlayCoordinator
|
||||
m_State.FreshCampaignLogRollId = newestRollId;
|
||||
}
|
||||
else if (!afterRollId.HasValue)
|
||||
{
|
||||
m_State.FreshCampaignLogRollId = null;
|
||||
}
|
||||
|
||||
m_State.CampaignLogCursor = page.Cursor ?? afterRollId;
|
||||
TrimCampaignLogDetails();
|
||||
@@ -91,12 +75,8 @@ public sealed class WorkspacePlayCoordinator
|
||||
}
|
||||
|
||||
var sheet = await m_WorkspaceQuery.GetCharacterSheetAsync(m_State.SelectedCharacterId.Value);
|
||||
m_State.SelectedCharacterSkillGroups = sheet.SkillGroups
|
||||
.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
m_State.SelectedCharacterSkills = sheet.Skills
|
||||
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
m_State.SelectedCharacterSkillGroups = sheet.SkillGroups.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
m_State.SelectedCharacterSkills = sheet.Skills.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
public Task EnsureSelectedCharacterActiveAsync()
|
||||
@@ -338,15 +318,15 @@ public sealed class WorkspacePlayCoordinator
|
||||
|
||||
private static CampaignRollDetail ToCampaignRollDetail(RollResult roll)
|
||||
{
|
||||
return new CampaignRollDetail(roll.RollId, roll.Breakdown, roll.Dice.ToArray());
|
||||
return new(roll.RollId, roll.Breakdown, roll.Dice.ToArray());
|
||||
}
|
||||
|
||||
private const int CampaignLogWindowSize = 25;
|
||||
|
||||
private readonly RpgRollerApiClient m_ApiClient;
|
||||
private readonly Func<CharacterSummary, bool> m_CanEditCharacter;
|
||||
private readonly WorkspaceFeedbackService m_Feedback;
|
||||
private readonly Func<Task> m_RequestRefreshAsync;
|
||||
private readonly WorkspaceState m_State;
|
||||
private readonly WorkspaceQueryService m_WorkspaceQuery;
|
||||
|
||||
private const int CampaignLogWindowSize = 25;
|
||||
}
|
||||
}
|
||||
@@ -5,21 +5,7 @@ namespace RpgRoller.Components.Pages;
|
||||
|
||||
public sealed class WorkspaceSessionCoordinator
|
||||
{
|
||||
public WorkspaceSessionCoordinator(
|
||||
WorkspaceState state,
|
||||
WorkspaceFeedbackService feedback,
|
||||
IJSRuntime js,
|
||||
RpgRollerApiClient apiClient,
|
||||
WorkspaceQueryService workspaceQuery,
|
||||
Func<Guid?, Task> reloadCampaignsAsync,
|
||||
Func<Task> reloadCharacterCampaignOptionsAsync,
|
||||
Func<Task> refreshCampaignScopeAsync,
|
||||
Func<Task> syncStateEventsAsync,
|
||||
Func<Task> stopStateEventsAsync,
|
||||
Func<Task> ensureAdminUsersLoadedAsync,
|
||||
Action resetCampaignLogDetailState,
|
||||
Func<Task> requestRefreshAsync,
|
||||
Func<string?, Task> onLoggedOutAsync)
|
||||
public WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> syncStateEventsAsync, Func<Task> stopStateEventsAsync, Func<Task> ensureAdminUsersLoadedAsync, Action resetCampaignLogDetailState, Func<Task> requestRefreshAsync, Func<string?, Task> onLoggedOutAsync)
|
||||
{
|
||||
m_State = state;
|
||||
m_Feedback = feedback;
|
||||
@@ -278,21 +264,6 @@ public sealed class WorkspaceSessionCoordinator
|
||||
return exception.Message.Contains("JavaScript interop calls cannot be issued", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private readonly RpgRollerApiClient m_ApiClient;
|
||||
private readonly WorkspaceFeedbackService m_Feedback;
|
||||
private readonly Func<Task> m_EnsureAdminUsersLoadedAsync;
|
||||
private readonly IJSRuntime m_JS;
|
||||
private readonly Func<string?, Task> m_OnLoggedOutAsync;
|
||||
private readonly Func<Task> m_ReloadCharacterCampaignOptionsAsync;
|
||||
private readonly Func<Guid?, Task> m_ReloadCampaignsAsync;
|
||||
private readonly Action m_ResetCampaignLogDetailState;
|
||||
private readonly Func<Task> m_RefreshCampaignScopeAsync;
|
||||
private readonly Func<Task> m_RequestRefreshAsync;
|
||||
private readonly WorkspaceState m_State;
|
||||
private readonly Func<Task> m_StopStateEventsAsync;
|
||||
private readonly Func<Task> m_SyncStateEventsAsync;
|
||||
private readonly WorkspaceQueryService m_WorkspaceQuery;
|
||||
|
||||
private const string ScreenPlay = "play";
|
||||
private const string ScreenManagement = "management";
|
||||
private const string ScreenAdmin = "admin";
|
||||
@@ -300,4 +271,19 @@ public sealed class WorkspaceSessionCoordinator
|
||||
private const string CampaignSessionKey = "campaign";
|
||||
private const string MobilePanelSessionKey = "play-panel";
|
||||
private const string RollVisibilitySessionKey = "roll-visibility";
|
||||
}
|
||||
|
||||
private readonly RpgRollerApiClient m_ApiClient;
|
||||
private readonly Func<Task> m_EnsureAdminUsersLoadedAsync;
|
||||
private readonly WorkspaceFeedbackService m_Feedback;
|
||||
private readonly IJSRuntime m_JS;
|
||||
private readonly Func<string?, Task> m_OnLoggedOutAsync;
|
||||
private readonly Func<Task> m_RefreshCampaignScopeAsync;
|
||||
private readonly Func<Guid?, Task> m_ReloadCampaignsAsync;
|
||||
private readonly Func<Task> m_ReloadCharacterCampaignOptionsAsync;
|
||||
private readonly Func<Task> m_RequestRefreshAsync;
|
||||
private readonly Action m_ResetCampaignLogDetailState;
|
||||
private readonly WorkspaceState m_State;
|
||||
private readonly Func<Task> m_StopStateEventsAsync;
|
||||
private readonly Func<Task> m_SyncStateEventsAsync;
|
||||
private readonly WorkspaceQueryService m_WorkspaceQuery;
|
||||
}
|
||||
@@ -1,11 +1,41 @@
|
||||
using RpgRoller.Components.Pages.HomeControls;
|
||||
using RpgRoller.Contracts;
|
||||
using RpgRoller.Domain;
|
||||
using RpgRoller.Components.Pages.HomeControls;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public sealed class WorkspaceState
|
||||
{
|
||||
public string OwnerLabel(Guid ownerUserId)
|
||||
{
|
||||
if (User is not null && ownerUserId == User.Id)
|
||||
return "You";
|
||||
|
||||
if (SelectedCampaign is null)
|
||||
return "Unknown owner";
|
||||
|
||||
if (ownerUserId == SelectedCampaign.Gm.Id)
|
||||
return $"{SelectedCampaign.Gm.DisplayName} (GM)";
|
||||
|
||||
var ownerDisplayName = SelectedCampaign.Characters.Where(character => character.OwnerUserId == ownerUserId).Select(character => character.OwnerDisplayName).FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
|
||||
|
||||
return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName;
|
||||
}
|
||||
|
||||
public string SkillDefinitionLabel(CharacterSheetSkill skill)
|
||||
{
|
||||
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
|
||||
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange);
|
||||
|
||||
return skill.DiceRollDefinition;
|
||||
}
|
||||
|
||||
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
|
||||
return $"{skill.DiceRollDefinition}, wild {skill.WildDice}, {fumbleLabel}";
|
||||
}
|
||||
|
||||
public UserSummary? User { get; set; }
|
||||
public Guid? ActiveCharacterId { get; set; }
|
||||
public Guid? SelectedCampaignId { get; set; }
|
||||
@@ -66,18 +96,11 @@ public sealed class WorkspaceState
|
||||
return null;
|
||||
|
||||
if (User is null)
|
||||
return new CampaignRoster(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, []);
|
||||
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, []);
|
||||
|
||||
var ownedCharacters = SelectedCampaign.Characters
|
||||
.Where(character => character.OwnerUserId == User.Id)
|
||||
.ToArray();
|
||||
var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id).ToArray();
|
||||
|
||||
return new CampaignRoster(
|
||||
SelectedCampaign.Id,
|
||||
SelectedCampaign.Name,
|
||||
SelectedCampaign.RulesetId,
|
||||
SelectedCampaign.Gm,
|
||||
ownedCharacters);
|
||||
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, ownedCharacters);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,37 +171,4 @@ public sealed class WorkspaceState
|
||||
};
|
||||
|
||||
public string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app";
|
||||
|
||||
public string OwnerLabel(Guid ownerUserId)
|
||||
{
|
||||
if (User is not null && ownerUserId == User.Id)
|
||||
return "You";
|
||||
|
||||
if (SelectedCampaign is null)
|
||||
return "Unknown owner";
|
||||
|
||||
if (ownerUserId == SelectedCampaign.Gm.Id)
|
||||
return $"{SelectedCampaign.Gm.DisplayName} (GM)";
|
||||
|
||||
var ownerDisplayName = SelectedCampaign.Characters
|
||||
.Where(character => character.OwnerUserId == ownerUserId)
|
||||
.Select(character => character.OwnerDisplayName)
|
||||
.FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
|
||||
|
||||
return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName;
|
||||
}
|
||||
|
||||
public string SkillDefinitionLabel(CharacterSheetSkill skill)
|
||||
{
|
||||
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
|
||||
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange);
|
||||
|
||||
return skill.DiceRollDefinition;
|
||||
}
|
||||
|
||||
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
|
||||
return $"{skill.DiceRollDefinition}, wild {skill.WildDice}, {fumbleLabel}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace RpgRoller.Components.Pages;
|
||||
|
||||
public sealed record WorkspaceToast(Guid Id, string Message, bool IsError);
|
||||
public sealed record WorkspaceToast(Guid Id, string Message, bool IsError);
|
||||
@@ -6,4 +6,4 @@
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
|
||||
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
|
||||
</Found>
|
||||
</Router>
|
||||
</Router>
|
||||
@@ -53,4 +53,4 @@ public sealed class ApiRequestException : Exception
|
||||
|
||||
public int StatusCode { get; }
|
||||
public string? ErrorCode { get; }
|
||||
}
|
||||
}
|
||||
@@ -82,9 +82,9 @@ public sealed class WorkspaceQueryService
|
||||
private static ApiRequestException ToApiRequestException(ServiceError error)
|
||||
{
|
||||
var statusCode = error.Code == "unauthorized" ? 401 : 400;
|
||||
return new ApiRequestException(statusCode, error.Message, error.Code);
|
||||
return new(statusCode, error.Message, error.Code);
|
||||
}
|
||||
|
||||
private readonly IGameService m_GameService;
|
||||
private readonly WorkspaceSessionTokenAccessor m_SessionTokenAccessor;
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,7 @@ public sealed class WorkspaceSessionTokenAccessor
|
||||
if (httpContext is null)
|
||||
return;
|
||||
|
||||
if (httpContext.Items.TryGetValue(SessionTokenItemKey, out var storedToken) &&
|
||||
storedToken is string sessionToken &&
|
||||
!string.IsNullOrWhiteSpace(sessionToken))
|
||||
if (httpContext.Items.TryGetValue(SessionTokenItemKey, out var storedToken) && storedToken is string sessionToken && !string.IsNullOrWhiteSpace(sessionToken))
|
||||
{
|
||||
m_SessionToken = sessionToken;
|
||||
return;
|
||||
@@ -32,4 +30,4 @@ public sealed class WorkspaceSessionTokenAccessor
|
||||
|
||||
private const string SessionTokenItemKey = "__rpgroller.session-token";
|
||||
private readonly string? m_SessionToken;
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,4 @@
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.JSInterop
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@@ -115,7 +115,8 @@ public sealed record CampaignLogListEntry(
|
||||
string VisibilityStyle,
|
||||
int Result,
|
||||
string SummaryText,
|
||||
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string[]? EventBadges,
|
||||
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
string[]? EventBadges,
|
||||
DateTimeOffset TimestampUtc);
|
||||
|
||||
public sealed record CampaignRollDetail(Guid RollId, string Breakdown, RollDieResult[] Dice);
|
||||
@@ -124,4 +125,4 @@ public sealed record CharacterStateVersion(Guid CharacterId, long Version);
|
||||
|
||||
public sealed record CampaignStateSnapshot(Guid CampaignId, long TotalVersion, long RosterVersion, long LogVersion, IReadOnlyList<CharacterStateVersion> CharacterVersions);
|
||||
|
||||
public sealed record CampaignLogPage(CampaignLogListEntry[] Entries, Guid? Cursor, bool HasMore, bool ResetRequired);
|
||||
public sealed record CampaignLogPage(CampaignLogListEntry[] Entries, Guid? Cursor, bool HasMore, bool ResetRequired);
|
||||
@@ -16,4 +16,4 @@ public static class RpgRollerJson
|
||||
if (!options.TypeInfoResolverChain.Contains(RpgRollerJsonSerializerContext.Default))
|
||||
options.TypeInfoResolverChain.Insert(0, RpgRollerJsonSerializerContext.Default);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,4 +56,4 @@ namespace RpgRoller.Contracts;
|
||||
[JsonSerializable(typeof(UserSummary))]
|
||||
public partial class RpgRollerJsonSerializerContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -91,4 +91,4 @@ public sealed class RpgRollerDbContext : DbContext
|
||||
public DbSet<Skill> Skills => Set<Skill>();
|
||||
public DbSet<SkillGroup> SkillGroups => Set<SkillGroup>();
|
||||
public DbSet<RollLogEntry> RollLogEntries => Set<RollLogEntry>();
|
||||
}
|
||||
}
|
||||
@@ -96,4 +96,4 @@ public enum DiceExpressionKind
|
||||
RolemasterOpenEndedPercentile
|
||||
}
|
||||
|
||||
public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical, DiceExpressionKind Kind = DiceExpressionKind.Standard);
|
||||
public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical, DiceExpressionKind Kind = DiceExpressionKind.Standard);
|
||||
@@ -40,4 +40,4 @@ public static class ServiceCollectionExtensions
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace RpgRoller.Hosting;
|
||||
|
||||
public sealed record SqliteDatabaseFile(string? Path);
|
||||
public sealed record SqliteDatabaseFile(string? Path);
|
||||
@@ -133,4 +133,4 @@ public static class SqliteSchemaUpgrader
|
||||
private const string CharactersCampaignDeletionMigrationId = "20260226160859_AddAuthorizationRolesAndCampaignDeletion";
|
||||
private const string AuthorizationRolesMigrationId = "20260226170000_AddAuthorizationRoles";
|
||||
private const string ProductVersion = "10.0.2";
|
||||
}
|
||||
}
|
||||
@@ -41,4 +41,4 @@ app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
||||
app.Run();
|
||||
|
||||
public partial class Program;
|
||||
public partial class Program;
|
||||
@@ -35,12 +35,7 @@ public static class CampaignLogSummaryBuilder
|
||||
|
||||
break;
|
||||
case RulesetKind.Rolemaster:
|
||||
AddBadgeIfMissing(
|
||||
badges,
|
||||
dice.Any(die =>
|
||||
string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) &&
|
||||
!die.SignedContribution.HasValue),
|
||||
"rf");
|
||||
AddBadgeIfMissing(badges, dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) && !die.SignedContribution.HasValue), "rf");
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 100), "r100");
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 66), "r66");
|
||||
break;
|
||||
@@ -63,17 +58,11 @@ public static class CampaignLogSummaryBuilder
|
||||
var openEndedInitial = dice.FirstOrDefault(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal));
|
||||
if (openEndedInitial is not null)
|
||||
{
|
||||
var highFollowUps = dice
|
||||
.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedHigh, StringComparison.Ordinal))
|
||||
.Select(die => die.Roll.ToString())
|
||||
.ToArray();
|
||||
var highFollowUps = dice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedHigh, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray();
|
||||
if (highFollowUps.Length > 0)
|
||||
return $"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high";
|
||||
|
||||
var lowFollowUps = dice
|
||||
.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal))
|
||||
.Select(die => die.Roll.ToString())
|
||||
.ToArray();
|
||||
var lowFollowUps = dice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray();
|
||||
if (lowFollowUps.Length > 0)
|
||||
return $"({RollBreakdownFormatter.FormatRolemasterTriggerRoll(openEndedInitial.Roll)}) {string.Join(" ", lowFollowUps.Select(roll => $"-{roll}"))} | open-ended low";
|
||||
|
||||
@@ -91,10 +80,7 @@ public static class CampaignLogSummaryBuilder
|
||||
|
||||
private static bool IsRolemasterDieKind(string? kind)
|
||||
{
|
||||
return kind is RollDieKinds.RolemasterStandard or
|
||||
RollDieKinds.RolemasterOpenEndedInitial or
|
||||
RollDieKinds.RolemasterOpenEndedHigh or
|
||||
RollDieKinds.RolemasterOpenEndedLowSubtract;
|
||||
return kind is RollDieKinds.RolemasterStandard or RollDieKinds.RolemasterOpenEndedInitial or RollDieKinds.RolemasterOpenEndedHigh or RollDieKinds.RolemasterOpenEndedLowSubtract;
|
||||
}
|
||||
|
||||
private static void AddBadgeIfMissing(List<string> badges, bool condition, string code)
|
||||
@@ -108,8 +94,6 @@ public static class CampaignLogSummaryBuilder
|
||||
private static bool IsSingleD20Expression(string expression)
|
||||
{
|
||||
var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression);
|
||||
return parsedExpression.Succeeded &&
|
||||
parsedExpression.Value!.DiceCount == 1 &&
|
||||
parsedExpression.Value.Sides == 20;
|
||||
return parsedExpression.Succeeded && parsedExpression.Value!.DiceCount == 1 && parsedExpression.Value.Sides == 20;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,6 @@ namespace RpgRoller.Services;
|
||||
|
||||
public static class CustomRollOptionsResolver
|
||||
{
|
||||
private const int DefaultCustomD6WildDice = 1;
|
||||
private const bool DefaultCustomD6AllowFumble = true;
|
||||
|
||||
public static (int WildDice, bool AllowFumble, int? FumbleRange) Resolve(RulesetKind ruleset)
|
||||
{
|
||||
return ruleset switch
|
||||
@@ -15,4 +12,7 @@ public static class CustomRollOptionsResolver
|
||||
_ => (0, false, null)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private const int DefaultCustomD6WildDice = 1;
|
||||
private const bool DefaultCustomD6AllowFumble = true;
|
||||
}
|
||||
@@ -91,4 +91,4 @@ public sealed class D6RollEngine
|
||||
}
|
||||
|
||||
private readonly IDiceRoller m_DiceRoller;
|
||||
}
|
||||
}
|
||||
@@ -86,16 +86,14 @@ public static partial class DiceRules
|
||||
var diceCount = string.IsNullOrEmpty(countValue) ? 1 : int.Parse(countValue);
|
||||
var sides = int.Parse(match.Groups["sides"].Value);
|
||||
var modifier = ParseModifier(match.Groups["modifier"].Value);
|
||||
var validation = ValidateDiceParts(diceCount, sides, modifier, -MaxModifier, MaxModifier);
|
||||
var validation = ValidateDiceParts(diceCount, sides, modifier, -MaxModifier);
|
||||
if (!validation.Succeeded)
|
||||
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
|
||||
|
||||
var isOpenEnded = match.Groups["openEnded"].Success;
|
||||
if (isOpenEnded && (diceCount != 1 || sides != 100))
|
||||
{
|
||||
return ServiceResult<DiceExpression>.Failure(
|
||||
"invalid_expression",
|
||||
"Open-ended Rolemaster rolls must use d100! with an implicit or explicit dice count of 1.");
|
||||
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Open-ended Rolemaster rolls must use d100! with an implicit or explicit dice count of 1.");
|
||||
}
|
||||
|
||||
var countPrefix = diceCount == 1 ? string.Empty : diceCount.ToString();
|
||||
@@ -152,4 +150,4 @@ public static partial class DiceRules
|
||||
(RulesetKind.Dnd5e, "dnd5e", "D&D 5e", "countdSides(+modifier), e.g. 2d12+2"),
|
||||
(RulesetKind.Rolemaster, "rolemaster", "Rolemaster", "countdSides(+/-modifier), e.g. d10, 15d10, d100-15, d100!+85")
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -141,4 +141,4 @@ public sealed class GameAuthService
|
||||
private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
|
||||
private readonly GamePersistenceService m_PersistenceService;
|
||||
private readonly GameStateStore m_StateStore;
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,7 @@ public static class GameAuthorization
|
||||
if (campaign.GmUserId == actorUserId)
|
||||
return true;
|
||||
|
||||
return stateStore.CharactersById.Values.Any(character =>
|
||||
character.CampaignId == campaignId &&
|
||||
character.OwnerUserId == actorUserId);
|
||||
return stateStore.CharactersById.Values.Any(character => character.CampaignId == campaignId && character.OwnerUserId == actorUserId);
|
||||
}
|
||||
|
||||
public static bool CanEditCharacter(Guid actorUserId, Character character, Campaign campaign)
|
||||
@@ -30,7 +28,6 @@ public static class GameAuthorization
|
||||
|
||||
public static bool CanViewRoll(GameStateStore stateStore, Guid actorUserId, Campaign campaign, RollLogEntry entry)
|
||||
{
|
||||
return CanViewCampaign(stateStore, actorUserId, campaign.Id) &&
|
||||
(entry.Visibility == RollVisibility.Public || entry.RollerUserId == actorUserId || campaign.GmUserId == actorUserId);
|
||||
return CanViewCampaign(stateStore, actorUserId, campaign.Id) && (entry.Visibility == RollVisibility.Public || entry.RollerUserId == actorUserId || campaign.GmUserId == actorUserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,11 +49,7 @@ public sealed class GameCampaignService
|
||||
if (user is null)
|
||||
return ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
var results = m_StateStore.CampaignsById.Values
|
||||
.Where(campaign => GameAuthorization.CanViewCampaign(m_StateStore, user.Id, campaign.Id))
|
||||
.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(campaign => GameDtoMapper.ToCampaignSummary(m_StateStore, campaign))
|
||||
.ToArray();
|
||||
var results = m_StateStore.CampaignsById.Values.Where(campaign => GameAuthorization.CanViewCampaign(m_StateStore, user.Id, campaign.Id)).OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(campaign => GameDtoMapper.ToCampaignSummary(m_StateStore, campaign)).ToArray();
|
||||
|
||||
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results);
|
||||
}
|
||||
@@ -67,10 +63,7 @@ public sealed class GameCampaignService
|
||||
if (user is null)
|
||||
return ServiceResult<IReadOnlyList<CampaignOption>>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
var options = m_StateStore.CampaignsById.Values
|
||||
.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(GameDtoMapper.ToCampaignOption)
|
||||
.ToArray();
|
||||
var options = m_StateStore.CampaignsById.Values.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(GameDtoMapper.ToCampaignOption).ToArray();
|
||||
|
||||
return ServiceResult<IReadOnlyList<CampaignOption>>.Success(options);
|
||||
}
|
||||
@@ -124,4 +117,4 @@ public sealed class GameCampaignService
|
||||
|
||||
private readonly GamePersistenceService m_PersistenceService;
|
||||
private readonly GameStateStore m_StateStore;
|
||||
}
|
||||
}
|
||||
@@ -62,9 +62,7 @@ public sealed class GameCharacterService
|
||||
|
||||
var isOwner = character.OwnerUserId == user.Id;
|
||||
var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin);
|
||||
var isSourceGm = character.CampaignId.HasValue &&
|
||||
m_StateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) &&
|
||||
sourceCampaign.GmUserId == user.Id;
|
||||
var isSourceGm = character.CampaignId.HasValue && m_StateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) && sourceCampaign.GmUserId == user.Id;
|
||||
var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id;
|
||||
if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm)
|
||||
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner, GM, or admin can edit this character.");
|
||||
@@ -85,12 +83,8 @@ public sealed class GameCharacterService
|
||||
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM or admin can change character owner.");
|
||||
|
||||
character.OwnerUserId = targetOwnerUserId;
|
||||
if (character.OwnerUserId != previousOwnerUserId &&
|
||||
m_StateStore.UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) &&
|
||||
previousOwner.ActiveCharacterId == character.Id)
|
||||
{
|
||||
if (character.OwnerUserId != previousOwnerUserId && m_StateStore.UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) && previousOwner.ActiveCharacterId == character.Id)
|
||||
previousOwner.ActiveCharacterId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceCampaignId != character.CampaignId)
|
||||
@@ -158,11 +152,7 @@ public sealed class GameCharacterService
|
||||
if (user is null)
|
||||
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
var characters = m_StateStore.CharactersById.Values
|
||||
.Where(character => character.OwnerUserId == user.Id)
|
||||
.OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(character => GameDtoMapper.ToCharacterSummary(m_StateStore, character))
|
||||
.ToArray();
|
||||
var characters = m_StateStore.CharactersById.Values.Where(character => character.OwnerUserId == user.Id).OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase).Select(character => GameDtoMapper.ToCharacterSummary(m_StateStore, character)).ToArray();
|
||||
|
||||
return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
|
||||
}
|
||||
@@ -200,4 +190,4 @@ public sealed class GameCharacterService
|
||||
|
||||
private readonly GamePersistenceService m_PersistenceService;
|
||||
private readonly GameStateStore m_StateStore;
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,9 @@ public static class GameContextResolver
|
||||
public static bool TryResolveCharacterCampaignLocked(GameStateStore stateStore, Character character, out Campaign campaign, out ServiceError? error)
|
||||
{
|
||||
campaign = default!;
|
||||
if (!character.CampaignId.HasValue ||
|
||||
!stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign) ||
|
||||
resolvedCampaign is null)
|
||||
if (!character.CampaignId.HasValue || !stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign) || resolvedCampaign is null)
|
||||
{
|
||||
error = new ServiceError("character_not_in_campaign", "Character is not linked to a campaign.");
|
||||
error = new("character_not_in_campaign", "Character is not linked to a campaign.");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -45,4 +43,4 @@ public static class GameContextResolver
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,19 +24,15 @@ public static class GameDtoMapper
|
||||
{
|
||||
var gm = stateStore.UsersById[campaign.GmUserId];
|
||||
var characterCount = stateStore.CharactersById.Values.Count(character => character.CampaignId == campaign.Id);
|
||||
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), new CampaignGmSummary(gm.Id, gm.DisplayName), characterCount);
|
||||
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), new(gm.Id, gm.DisplayName), characterCount);
|
||||
}
|
||||
|
||||
public static CampaignRoster ToCampaignRoster(GameStateStore stateStore, Campaign campaign)
|
||||
{
|
||||
var gm = stateStore.UsersById[campaign.GmUserId];
|
||||
var characters = stateStore.CharactersById.Values
|
||||
.Where(character => character.CampaignId == campaign.Id)
|
||||
.OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(character => ToCharacterSummary(stateStore, character))
|
||||
.ToArray();
|
||||
var characters = stateStore.CharactersById.Values.Where(character => character.CampaignId == campaign.Id).OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase).Select(character => ToCharacterSummary(stateStore, character)).ToArray();
|
||||
|
||||
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), new CampaignGmSummary(gm.Id, gm.DisplayName), characters);
|
||||
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), new(gm.Id, gm.DisplayName), characters);
|
||||
}
|
||||
|
||||
public static CharacterSummary ToCharacterSummary(GameStateStore stateStore, Character character)
|
||||
@@ -46,16 +42,8 @@ public static class GameDtoMapper
|
||||
|
||||
public static CharacterSheet ToCharacterSheet(GameStateStore stateStore, Guid characterId)
|
||||
{
|
||||
var skillGroups = stateStore.SkillGroupsById.Values
|
||||
.Where(group => group.CharacterId == characterId)
|
||||
.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(ToCharacterSheetSkillGroup)
|
||||
.ToArray();
|
||||
var skills = stateStore.SkillsById.Values
|
||||
.Where(skill => skill.CharacterId == characterId)
|
||||
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(ToCharacterSheetSkill)
|
||||
.ToArray();
|
||||
var skillGroups = stateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId).OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).Select(ToCharacterSheetSkillGroup).ToArray();
|
||||
var skills = stateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId).OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).Select(ToCharacterSheetSkill).ToArray();
|
||||
|
||||
return new(characterId, skillGroups, skills);
|
||||
}
|
||||
@@ -77,43 +65,12 @@ public static class GameDtoMapper
|
||||
|
||||
public static CampaignLogEntry ToCampaignLogEntry(RollLogEntry entry, string characterName, string skillName, string rollerDisplayName, IReadOnlyList<RollDieResult> dice)
|
||||
{
|
||||
return new(
|
||||
entry.Id,
|
||||
entry.CampaignId,
|
||||
entry.CharacterId,
|
||||
characterName,
|
||||
entry.SkillId,
|
||||
skillName,
|
||||
entry.RollerUserId,
|
||||
rollerDisplayName,
|
||||
entry.Visibility == RollVisibility.Public ? "public" : "private",
|
||||
entry.Result,
|
||||
entry.Breakdown,
|
||||
dice,
|
||||
entry.TimestampUtc);
|
||||
return new(entry.Id, entry.CampaignId, entry.CharacterId, characterName, entry.SkillId, skillName, entry.RollerUserId, rollerDisplayName, entry.Visibility == RollVisibility.Public ? "public" : "private", entry.Result, entry.Breakdown, dice, entry.TimestampUtc);
|
||||
}
|
||||
|
||||
public static CampaignLogListEntry ToCampaignLogListEntry(
|
||||
RollLogEntry entry,
|
||||
string characterName,
|
||||
string skillName,
|
||||
string rollerLabel,
|
||||
string visibilityLabel,
|
||||
string visibilityStyle,
|
||||
string summaryText,
|
||||
string[]? eventBadges)
|
||||
public static CampaignLogListEntry ToCampaignLogListEntry(RollLogEntry entry, string characterName, string skillName, string rollerLabel, string visibilityLabel, string visibilityStyle, string summaryText, string[]? eventBadges)
|
||||
{
|
||||
return new(
|
||||
entry.Id,
|
||||
characterName,
|
||||
skillName,
|
||||
rollerLabel,
|
||||
visibilityLabel,
|
||||
visibilityStyle,
|
||||
entry.Result,
|
||||
summaryText,
|
||||
eventBadges,
|
||||
entry.TimestampUtc);
|
||||
return new(entry.Id, characterName, skillName, rollerLabel, visibilityLabel, visibilityStyle, entry.Result, summaryText, eventBadges, entry.TimestampUtc);
|
||||
}
|
||||
|
||||
public static CampaignRollDetail ToCampaignRollDetail(RollLogEntry entry, RollDieResult[] dice)
|
||||
@@ -124,19 +81,14 @@ public static class GameDtoMapper
|
||||
public static CampaignStateSnapshot ToCampaignStateSnapshot(GameStateStore stateStore, Guid campaignId)
|
||||
{
|
||||
var state = stateStore.GetOrCreateCampaignStateLocked(campaignId);
|
||||
var characterVersions = state.CharacterVersions
|
||||
.OrderBy(version => version.Key)
|
||||
.Select(version => new CharacterStateVersion(version.Key, version.Value))
|
||||
.ToArray();
|
||||
var characterVersions = state.CharacterVersions.OrderBy(version => version.Key).Select(version => new CharacterStateVersion(version.Key, version.Value)).ToArray();
|
||||
|
||||
return new CampaignStateSnapshot(campaignId, state.TotalVersion, state.RosterVersion, state.LogVersion, characterVersions);
|
||||
return new(campaignId, state.TotalVersion, state.RosterVersion, state.LogVersion, characterVersions);
|
||||
}
|
||||
|
||||
public static string ResolveOwnerDisplayName(GameStateStore stateStore, Guid ownerUserId, string fallback)
|
||||
{
|
||||
return stateStore.UsersById.TryGetValue(ownerUserId, out var user) && !string.IsNullOrWhiteSpace(user.DisplayName)
|
||||
? user.DisplayName
|
||||
: fallback;
|
||||
return stateStore.UsersById.TryGetValue(ownerUserId, out var user) && !string.IsNullOrWhiteSpace(user.DisplayName) ? user.DisplayName : fallback;
|
||||
}
|
||||
|
||||
private static CharacterSheetSkillGroup ToCharacterSheetSkillGroup(SkillGroup skillGroup)
|
||||
@@ -148,4 +100,4 @@ public static class GameDtoMapper
|
||||
{
|
||||
return new(skill.Id, skill.SkillGroupId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble, skill.FumbleRange);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,4 +106,4 @@ public sealed class GamePersistenceService
|
||||
|
||||
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
|
||||
private readonly GameStateStore m_StateStore;
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,7 @@ public sealed class GameRollService
|
||||
m_StateStore = stateStore;
|
||||
m_PersistenceService = persistenceService;
|
||||
m_DiceRoller = diceRoller;
|
||||
m_RollEngine = new(
|
||||
new StandardRollEngine(diceRoller),
|
||||
new D6RollEngine(diceRoller),
|
||||
new RolemasterRollEngine(diceRoller));
|
||||
m_RollEngine = new(new(diceRoller), new(diceRoller), new(diceRoller));
|
||||
}
|
||||
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility)
|
||||
@@ -88,10 +85,7 @@ public sealed class GameRollService
|
||||
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Failure(context.Error!.Code, context.Error.Message);
|
||||
|
||||
var (user, campaign) = context.Value!;
|
||||
var entries = GetVisibleCampaignLogEntriesLocked(user, campaign)
|
||||
.TakeLast(CampaignLogHistoryWindowSize)
|
||||
.Select(ToLogEntry)
|
||||
.ToArray();
|
||||
var entries = GetVisibleCampaignLogEntriesLocked(user, campaign).TakeLast(CampaignLogHistoryWindowSize).Select(ToLogEntry).ToArray();
|
||||
|
||||
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries);
|
||||
}
|
||||
@@ -112,28 +106,28 @@ public sealed class GameRollService
|
||||
if (!afterRollId.HasValue)
|
||||
{
|
||||
var initialEntries = visibleEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
|
||||
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(initialEntries, initialEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, false));
|
||||
return ServiceResult<CampaignLogPage>.Success(new(initialEntries, initialEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, false));
|
||||
}
|
||||
|
||||
var afterIndex = Array.FindIndex(visibleEntries, entry => entry.Id == afterRollId.Value);
|
||||
if (afterIndex < 0)
|
||||
{
|
||||
var replacementEntries = visibleEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
|
||||
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(replacementEntries, replacementEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, true));
|
||||
return ServiceResult<CampaignLogPage>.Success(new(replacementEntries, replacementEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, true));
|
||||
}
|
||||
|
||||
var newEntries = visibleEntries.Skip(afterIndex + 1).ToArray();
|
||||
if (newEntries.Length == 0)
|
||||
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage([], afterRollId, false, false));
|
||||
return ServiceResult<CampaignLogPage>.Success(new([], afterRollId, false, false));
|
||||
|
||||
if (newEntries.Length > pageSize)
|
||||
{
|
||||
var replacementEntries = newEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
|
||||
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(replacementEntries, replacementEntries[^1].RollId, true, true));
|
||||
return ServiceResult<CampaignLogPage>.Success(new(replacementEntries, replacementEntries[^1].RollId, true, true));
|
||||
}
|
||||
|
||||
var appendedEntries = newEntries.Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
|
||||
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(appendedEntries, appendedEntries[^1].RollId, false, false));
|
||||
return ServiceResult<CampaignLogPage>.Success(new(appendedEntries, appendedEntries[^1].RollId, false, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,14 +162,7 @@ public sealed class GameRollService
|
||||
}
|
||||
}
|
||||
|
||||
private ServiceResult<RollResult> RecordRollLocked(
|
||||
UserAccount user,
|
||||
Campaign campaign,
|
||||
Character character,
|
||||
Guid skillId,
|
||||
RollVisibility visibility,
|
||||
(int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) roll,
|
||||
string canonicalExpression)
|
||||
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
|
||||
{
|
||||
@@ -200,18 +187,12 @@ public sealed class GameRollService
|
||||
|
||||
private static string FormatLoggedBreakdown(Guid skillId, string canonicalExpression, string breakdown)
|
||||
{
|
||||
return skillId == CustomRollSkillId
|
||||
? $"{canonicalExpression}{CustomRollBreakdownSeparator}{breakdown}"
|
||||
: breakdown;
|
||||
return skillId == CustomRollSkillId ? $"{canonicalExpression}{CustomRollBreakdownSeparator}{breakdown}" : breakdown;
|
||||
}
|
||||
|
||||
private IEnumerable<RollLogEntry> GetVisibleCampaignLogEntriesLocked(UserAccount user, Campaign campaign)
|
||||
{
|
||||
return m_StateStore.RollLog
|
||||
.Where(r => r.CampaignId == campaign.Id)
|
||||
.Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id)
|
||||
.OrderBy(r => r.TimestampUtc)
|
||||
.ThenBy(r => r.Id);
|
||||
return m_StateStore.RollLog.Where(r => r.CampaignId == campaign.Id).Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id).OrderBy(r => r.TimestampUtc).ThenBy(r => r.Id);
|
||||
}
|
||||
|
||||
private CampaignLogEntry ToLogEntry(RollLogEntry entry)
|
||||
@@ -232,15 +213,7 @@ public sealed class GameRollService
|
||||
var loggedExpression = ResolveLoggedExpression(entry);
|
||||
var eventBadges = CampaignLogSummaryBuilder.BuildCompactLogEventBadges(campaign.Ruleset, loggedExpression, dice);
|
||||
|
||||
return GameDtoMapper.ToCampaignLogListEntry(
|
||||
entry,
|
||||
characterName,
|
||||
skillName,
|
||||
ResolveLogRollerLabel(user, campaign, entry),
|
||||
ResolveLogVisibilityLabel(user, campaign, entry),
|
||||
ResolveLogVisibilityStyle(user, campaign, entry),
|
||||
CampaignLogSummaryBuilder.BuildCompactLogSummary(dice),
|
||||
eventBadges);
|
||||
return GameDtoMapper.ToCampaignLogListEntry(entry, characterName, skillName, ResolveLogRollerLabel(user, campaign, entry), ResolveLogVisibilityLabel(user, campaign, entry), ResolveLogVisibilityStyle(user, campaign, entry), CampaignLogSummaryBuilder.BuildCompactLogSummary(dice), eventBadges);
|
||||
}
|
||||
|
||||
private static string SerializeDice(IReadOnlyList<RollDieResult> dice)
|
||||
@@ -323,11 +296,11 @@ public sealed class GameRollService
|
||||
private const int CampaignLogHistoryWindowSize = 100;
|
||||
private const int CampaignLogLivePageSize = 25;
|
||||
private const string CustomRollBreakdownSeparator = " => ";
|
||||
private static readonly Guid CustomRollSkillId = Guid.Empty;
|
||||
private const string CustomRollLabel = "Custom roll";
|
||||
private static readonly Guid CustomRollSkillId = Guid.Empty;
|
||||
private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions();
|
||||
private readonly IDiceRoller m_DiceRoller;
|
||||
private readonly GamePersistenceService m_PersistenceService;
|
||||
private readonly RollEngine m_RollEngine;
|
||||
private readonly GameStateStore m_StateStore;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,9 @@ public sealed class GameService : IGameService
|
||||
m_UserAdministrationService = new(m_StateStore, m_PersistenceService);
|
||||
m_PersistenceService.LoadStateFromDatabase();
|
||||
lock (m_StateStore.Gate)
|
||||
{
|
||||
m_StateStore.RebuildCampaignStateLocked();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<RulesetDefinition> GetRulesets()
|
||||
@@ -188,12 +190,13 @@ public sealed class GameService : IGameService
|
||||
return m_RollService.GetCampaignStateSnapshot(sessionToken, campaignId);
|
||||
}
|
||||
|
||||
private readonly GameAuthService m_AuthService;
|
||||
|
||||
private readonly GameCampaignService m_CampaignService;
|
||||
private readonly GameCharacterService m_CharacterService;
|
||||
private readonly GameAuthService m_AuthService;
|
||||
private readonly GamePersistenceService m_PersistenceService;
|
||||
private readonly GameRollService m_RollService;
|
||||
private readonly GameSkillService m_SkillService;
|
||||
private readonly GameStateStore m_StateStore;
|
||||
private readonly GameUserAdministrationService m_UserAdministrationService;
|
||||
}
|
||||
}
|
||||
@@ -273,4 +273,4 @@ public sealed class GameSkillService
|
||||
|
||||
private readonly GamePersistenceService m_PersistenceService;
|
||||
private readonly GameStateStore m_StateStore;
|
||||
}
|
||||
}
|
||||
@@ -96,4 +96,4 @@ public static class GameStateCloneFactory
|
||||
TimestampUtc = entry.TimestampUtc
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,11 @@ namespace RpgRoller.Services;
|
||||
|
||||
public sealed class GameStateStore
|
||||
{
|
||||
public object Gate { get; } = new();
|
||||
public Dictionary<Guid, Campaign> CampaignsById { get; } = [];
|
||||
public Dictionary<Guid, GameCampaignStateTracker> CampaignStateById { get; } = [];
|
||||
public Dictionary<Guid, Character> CharactersById { get; } = [];
|
||||
public List<RollLogEntry> RollLog { get; } = [];
|
||||
public Dictionary<string, UserSession> SessionsByToken { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<Guid, SkillGroup> SkillGroupsById { get; } = [];
|
||||
public Dictionary<Guid, Skill> SkillsById { get; } = [];
|
||||
public Dictionary<string, Guid> UserIdsByUsername { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<Guid, UserAccount> UsersById { get; } = [];
|
||||
|
||||
public GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId)
|
||||
{
|
||||
if (!CampaignStateById.TryGetValue(campaignId, out var state))
|
||||
{
|
||||
state = new GameCampaignStateTracker();
|
||||
state = new();
|
||||
CampaignStateById[campaignId] = state;
|
||||
}
|
||||
|
||||
@@ -31,7 +20,7 @@ public sealed class GameStateStore
|
||||
CampaignStateById.Clear();
|
||||
|
||||
foreach (var campaignId in CampaignsById.Keys)
|
||||
CampaignStateById[campaignId] = new GameCampaignStateTracker();
|
||||
CampaignStateById[campaignId] = new();
|
||||
|
||||
foreach (var character in CharactersById.Values.Where(character => character.CampaignId.HasValue))
|
||||
AddCharacterStateLocked(character.CampaignId, character.Id);
|
||||
@@ -83,6 +72,17 @@ public sealed class GameStateStore
|
||||
state.TotalVersion += 1;
|
||||
state.LogVersion += 1;
|
||||
}
|
||||
|
||||
public object Gate { get; } = new();
|
||||
public Dictionary<Guid, Campaign> CampaignsById { get; } = [];
|
||||
public Dictionary<Guid, GameCampaignStateTracker> CampaignStateById { get; } = [];
|
||||
public Dictionary<Guid, Character> CharactersById { get; } = [];
|
||||
public List<RollLogEntry> RollLog { get; } = [];
|
||||
public Dictionary<string, UserSession> SessionsByToken { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<Guid, SkillGroup> SkillGroupsById { get; } = [];
|
||||
public Dictionary<Guid, Skill> SkillsById { get; } = [];
|
||||
public Dictionary<string, Guid> UserIdsByUsername { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<Guid, UserAccount> UsersById { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class GameCampaignStateTracker
|
||||
@@ -91,4 +91,4 @@ public sealed class GameCampaignStateTracker
|
||||
public long RosterVersion { get; set; } = 1;
|
||||
public long LogVersion { get; set; } = 1;
|
||||
public Dictionary<Guid, long> CharacterVersions { get; } = [];
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,7 @@ public sealed class GameUserAdministrationService
|
||||
if (user is null)
|
||||
return ServiceResult<IReadOnlyList<string>>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
var usernames = m_StateStore.UsersById.Values
|
||||
.Select(account => account.Username)
|
||||
.OrderBy(username => username, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var usernames = m_StateStore.UsersById.Values.Select(account => account.Username).OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
|
||||
return ServiceResult<IReadOnlyList<string>>.Success(usernames);
|
||||
}
|
||||
@@ -39,10 +36,7 @@ public sealed class GameUserAdministrationService
|
||||
if (!GameAuthorization.HasRole(user, UserRoles.Admin))
|
||||
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("forbidden", "Admin role is required.");
|
||||
|
||||
var users = m_StateStore.UsersById.Values
|
||||
.OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(GameDtoMapper.ToAdminUserSummary)
|
||||
.ToArray();
|
||||
var users = m_StateStore.UsersById.Values.OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase).Select(GameDtoMapper.ToAdminUserSummary).ToArray();
|
||||
|
||||
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Success(users);
|
||||
}
|
||||
@@ -92,32 +86,20 @@ public sealed class GameUserAdministrationService
|
||||
if (!m_StateStore.UsersById.TryGetValue(userId, out var targetUser))
|
||||
return ServiceResult<bool>.Failure("user_not_found", "User was not found.");
|
||||
|
||||
var gmCampaignIds = m_StateStore.CampaignsById.Values
|
||||
.Where(campaign => campaign.GmUserId == targetUser.Id)
|
||||
.Select(campaign => campaign.Id)
|
||||
.ToArray();
|
||||
var gmCampaignIds = m_StateStore.CampaignsById.Values.Where(campaign => campaign.GmUserId == targetUser.Id).Select(campaign => campaign.Id).ToArray();
|
||||
var gmCampaignIdSet = gmCampaignIds.ToHashSet();
|
||||
var preservedCharacterIds = m_StateStore.CharactersById.Values
|
||||
.Where(character => character.CampaignId.HasValue && gmCampaignIdSet.Contains(character.CampaignId.Value))
|
||||
.Select(character => character.Id)
|
||||
.ToHashSet();
|
||||
var preservedCharacterIds = m_StateStore.CharactersById.Values.Where(character => character.CampaignId.HasValue && gmCampaignIdSet.Contains(character.CampaignId.Value)).Select(character => character.Id).ToHashSet();
|
||||
|
||||
foreach (var campaignId in gmCampaignIds)
|
||||
DeleteCampaignLocked(campaignId);
|
||||
|
||||
var ownedCharacterIds = m_StateStore.CharactersById.Values
|
||||
.Where(character => character.OwnerUserId == targetUser.Id && !preservedCharacterIds.Contains(character.Id))
|
||||
.Select(character => character.Id)
|
||||
.ToArray();
|
||||
var ownedCharacterIds = m_StateStore.CharactersById.Values.Where(character => character.OwnerUserId == targetUser.Id && !preservedCharacterIds.Contains(character.Id)).Select(character => character.Id).ToArray();
|
||||
foreach (var characterId in ownedCharacterIds)
|
||||
DeleteCharacterLocked(characterId);
|
||||
|
||||
m_StateStore.RollLog.RemoveAll(entry => entry.RollerUserId == targetUser.Id);
|
||||
|
||||
var staleSessions = m_StateStore.SessionsByToken.Values
|
||||
.Where(session => session.UserId == targetUser.Id)
|
||||
.Select(session => session.Token)
|
||||
.ToArray();
|
||||
var staleSessions = m_StateStore.SessionsByToken.Values.Where(session => session.UserId == targetUser.Id).Select(session => session.Token).ToArray();
|
||||
foreach (var token in staleSessions)
|
||||
m_StateStore.SessionsByToken.Remove(token);
|
||||
|
||||
@@ -134,10 +116,7 @@ public sealed class GameUserAdministrationService
|
||||
if (!m_StateStore.CampaignsById.Remove(campaignId))
|
||||
return;
|
||||
|
||||
var affectedCharacterIds = m_StateStore.CharactersById.Values
|
||||
.Where(character => character.CampaignId == campaignId)
|
||||
.Select(character => character.Id)
|
||||
.ToArray();
|
||||
var affectedCharacterIds = m_StateStore.CharactersById.Values.Where(character => character.CampaignId == campaignId).Select(character => character.Id).ToArray();
|
||||
foreach (var characterId in affectedCharacterIds)
|
||||
m_StateStore.CharactersById[characterId].CampaignId = null;
|
||||
|
||||
@@ -153,17 +132,11 @@ public sealed class GameUserAdministrationService
|
||||
var campaignId = character.CampaignId;
|
||||
m_StateStore.CharactersById.Remove(characterId);
|
||||
|
||||
var skillGroupIds = m_StateStore.SkillGroupsById.Values
|
||||
.Where(group => group.CharacterId == characterId)
|
||||
.Select(group => group.Id)
|
||||
.ToHashSet();
|
||||
var skillGroupIds = m_StateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet();
|
||||
foreach (var skillGroupId in skillGroupIds)
|
||||
m_StateStore.SkillGroupsById.Remove(skillGroupId);
|
||||
|
||||
var skillIds = m_StateStore.SkillsById.Values
|
||||
.Where(skill => skill.CharacterId == characterId)
|
||||
.Select(skill => skill.Id)
|
||||
.ToHashSet();
|
||||
var skillIds = m_StateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet();
|
||||
foreach (var skillId in skillIds)
|
||||
m_StateStore.SkillsById.Remove(skillId);
|
||||
|
||||
@@ -178,4 +151,4 @@ public sealed class GameUserAdministrationService
|
||||
|
||||
private readonly GamePersistenceService m_PersistenceService;
|
||||
private readonly GameStateStore m_StateStore;
|
||||
}
|
||||
}
|
||||
@@ -43,4 +43,4 @@ public interface IGameService
|
||||
ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId);
|
||||
|
||||
ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId);
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,11 @@ public static class RoleSerializer
|
||||
|
||||
public static string[] Normalize(IEnumerable<string> roles)
|
||||
{
|
||||
return roles
|
||||
.Where(role => !string.IsNullOrWhiteSpace(role))
|
||||
.Select(role => role.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(role => role, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
return roles.Where(role => !string.IsNullOrWhiteSpace(role)).Select(role => role.Trim().ToLowerInvariant()).Distinct(StringComparer.Ordinal).OrderBy(role => role, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
public static bool HasRole(string serializedRoles, string role)
|
||||
{
|
||||
return Parse(serializedRoles).Contains(role, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public sealed class RolemasterRollEngine
|
||||
return expression.Kind switch
|
||||
{
|
||||
DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault()),
|
||||
_ => RollStandard(expression)
|
||||
_ => RollStandard(expression)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,22 +40,19 @@ public sealed class RolemasterRollEngine
|
||||
var initialRoll = m_DiceRoller.Roll(expression.Sides);
|
||||
var followUpRolls = new List<int>();
|
||||
int? initialContribution = initialRoll <= fumbleRange ? null : initialRoll;
|
||||
var dice = new List<RollDieResult>
|
||||
{
|
||||
CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution)
|
||||
};
|
||||
var dice = new List<RollDieResult> { CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution) };
|
||||
|
||||
var baseTotal = initialRoll <= fumbleRange ? 0 : initialRoll;
|
||||
var subtractFollowUps = false;
|
||||
if (initialRoll >= 96)
|
||||
{
|
||||
followUpRolls.AddRange(RollHighOpenEndedChain(dice, sequenceStart: 2, subtract: false));
|
||||
followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, false));
|
||||
baseTotal += followUpRolls.Sum();
|
||||
}
|
||||
else if (initialRoll <= fumbleRange)
|
||||
{
|
||||
subtractFollowUps = true;
|
||||
followUpRolls.AddRange(RollHighOpenEndedChain(dice, sequenceStart: 2, subtract: true));
|
||||
followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, true));
|
||||
baseTotal -= followUpRolls.Sum();
|
||||
}
|
||||
|
||||
@@ -73,11 +70,7 @@ public sealed class RolemasterRollEngine
|
||||
{
|
||||
var roll = m_DiceRoller.Roll(100);
|
||||
followUpRolls.Add(roll);
|
||||
dice.Add(CreateRolemasterDie(
|
||||
roll,
|
||||
sequence,
|
||||
subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh,
|
||||
subtract ? -roll : roll));
|
||||
dice.Add(CreateRolemasterDie(roll, sequence, subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh, subtract ? -roll : roll));
|
||||
|
||||
sequence += 1;
|
||||
if (roll < 96)
|
||||
@@ -93,4 +86,4 @@ public sealed class RolemasterRollEngine
|
||||
}
|
||||
|
||||
private readonly IDiceRoller m_DiceRoller;
|
||||
}
|
||||
}
|
||||
@@ -49,4 +49,4 @@ public static class RollBreakdownFormatter
|
||||
_ => $"{core}={total}"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,4 +26,4 @@ public sealed class RollEngine
|
||||
private readonly D6RollEngine m_D6RollEngine;
|
||||
private readonly RolemasterRollEngine m_RolemasterRollEngine;
|
||||
private readonly StandardRollEngine m_StandardRollEngine;
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,4 @@ public static class RollVisibilityParser
|
||||
|
||||
return ServiceResult<RollVisibility>.Failure("invalid_visibility", "Visibility must be 'public' or 'private'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,7 @@ namespace RpgRoller.Services;
|
||||
|
||||
public static class SkillDefinitionValidator
|
||||
{
|
||||
public static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)> Validate(
|
||||
RulesetKind ruleset,
|
||||
string diceRollDefinition,
|
||||
int wildDice,
|
||||
bool allowFumble,
|
||||
int? fumbleRange)
|
||||
public static ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)> Validate(RulesetKind ruleset, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange)
|
||||
{
|
||||
var expressionValidation = DiceRules.ParseExpression(ruleset, diceRollDefinition);
|
||||
if (!expressionValidation.Succeeded)
|
||||
@@ -19,19 +14,10 @@ public static class SkillDefinitionValidator
|
||||
if (!optionsValidation.Succeeded)
|
||||
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
|
||||
|
||||
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Success((
|
||||
expressionValidation.Value!.Canonical,
|
||||
optionsValidation.Value!.WildDice,
|
||||
optionsValidation.Value.AllowFumble,
|
||||
optionsValidation.Value.FumbleRange));
|
||||
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value!.WildDice, optionsValidation.Value.AllowFumble, optionsValidation.Value.FumbleRange));
|
||||
}
|
||||
|
||||
private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)> ValidateOptions(
|
||||
RulesetKind ruleset,
|
||||
DiceExpression expression,
|
||||
int wildDice,
|
||||
bool allowFumble,
|
||||
int? fumbleRange)
|
||||
private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)> ValidateOptions(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange)
|
||||
{
|
||||
if (wildDice < 0 || wildDice > 50)
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50.");
|
||||
@@ -77,4 +63,4 @@ public static class SkillDefinitionValidator
|
||||
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,4 +27,4 @@ public sealed class StandardRollEngine
|
||||
}
|
||||
|
||||
private readonly IDiceRoller m_DiceRoller;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user