Code Cleanup

This commit is contained in:
2026-04-05 01:32:52 +02:00
parent 305999e4b7
commit 46a63f9e06
109 changed files with 939 additions and 1125 deletions

View File

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

View File

@@ -18,4 +18,4 @@ public static class ApiEndpointRegistration
authenticatedApi.MapRollEndpoints();
authenticatedApi.MapStateEventEndpoints();
}
}
}

View File

@@ -21,4 +21,4 @@ internal static class ApiResultMapper
{
return TypedResults.BadRequest(new ApiError(error.Message, error.Code));
}
}
}

View File

@@ -51,4 +51,4 @@ internal static class CampaignEndpoints
return group;
}
}
}

View File

@@ -51,4 +51,4 @@ internal static class CharacterEndpoints
return group;
}
}
}

View File

@@ -1,4 +1,3 @@
using RpgRoller.Contracts;
using RpgRoller.Services;
namespace RpgRoller.Api;
@@ -15,4 +14,4 @@ internal static class RollEndpoints
return group;
}
}
}

View File

@@ -57,4 +57,4 @@ internal static class SkillEndpoints
return group;
}
}
}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
@inherits LayoutComponentBase
@attribute [ExcludeFromCodeCoverage]
@Body
@Body

View File

@@ -70,4 +70,4 @@ public enum HomeViewMode
Loading,
Anonymous,
Workspace
}
}

View File

@@ -24,4 +24,4 @@
case HomeViewMode.Workspace:
<Workspace LoggedOut="OnLoggedOutAsync"/>
break;
}
}

View File

@@ -77,4 +77,4 @@ public partial class Home
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
}
}

View File

@@ -68,4 +68,4 @@
</section>
</main>
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

@@ -63,4 +63,4 @@
</form>
</section>
</div>
</main>
</main>

View File

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

View File

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

View File

@@ -129,4 +129,4 @@
</form>
</section>
</div>
}
}

View File

@@ -115,4 +115,4 @@ public partial class CampaignManagementPanel
[Parameter]
public EventCallback<CharacterSummary> DeleteCharacterRequested { get; set; }
}
}

View File

@@ -48,4 +48,4 @@
</form>
</section>
</div>
}
}

View File

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

View File

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

View File

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

View File

@@ -6,4 +6,4 @@
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieDisplay(die)</span>
}
</div>
}
}

View File

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

View File

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

View File

@@ -65,4 +65,4 @@
</form>
</section>
</div>
}
}

View File

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

View File

@@ -77,4 +77,4 @@
<span>Add skill</span>
</button>
</div>
</div>
</div>

View File

@@ -57,4 +57,4 @@ public partial class SkillGroupBlock
[Parameter]
public EventCallback<Guid> DeleteGroupRequested { get; set; }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,4 +6,4 @@
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
</Found>
</Router>
</Router>

View File

@@ -53,4 +53,4 @@ public sealed class ApiRequestException : Exception
public int StatusCode { get; }
public string? ErrorCode { get; }
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,4 +16,4 @@ public static class RpgRollerJson
if (!options.TypeInfoResolverChain.Contains(RpgRollerJsonSerializerContext.Default))
options.TypeInfoResolverChain.Insert(0, RpgRollerJsonSerializerContext.Default);
}
}
}

View File

@@ -56,4 +56,4 @@ namespace RpgRoller.Contracts;
[JsonSerializable(typeof(UserSummary))]
public partial class RpgRollerJsonSerializerContext : JsonSerializerContext
{
}
}

View File

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

View File

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

View File

@@ -40,4 +40,4 @@ public static class ServiceCollectionExtensions
if (!string.IsNullOrWhiteSpace(directory))
Directory.CreateDirectory(directory);
}
}
}

View File

@@ -1,3 +1,3 @@
namespace RpgRoller.Hosting;
public sealed record SqliteDatabaseFile(string? Path);
public sealed record SqliteDatabaseFile(string? Path);

View File

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

View File

@@ -41,4 +41,4 @@ app.MapStaticAssets();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run();
public partial class Program;
public partial class Program;

View File

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

View File

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

View File

@@ -91,4 +91,4 @@ public sealed class D6RollEngine
}
private readonly IDiceRoller m_DiceRoller;
}
}

View File

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

View File

@@ -141,4 +141,4 @@ public sealed class GameAuthService
private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
private readonly GamePersistenceService m_PersistenceService;
private readonly GameStateStore m_StateStore;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -106,4 +106,4 @@ public sealed class GamePersistenceService
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
private readonly GameStateStore m_StateStore;
}
}

View File

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

View File

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

View File

@@ -273,4 +273,4 @@ public sealed class GameSkillService
private readonly GamePersistenceService m_PersistenceService;
private readonly GameStateStore m_StateStore;
}
}

View File

@@ -96,4 +96,4 @@ public static class GameStateCloneFactory
TimestampUtc = entry.TimestampUtc
};
}
}
}

View File

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

View File

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

View File

@@ -43,4 +43,4 @@ public interface IGameService
ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId);
ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId);
}
}

View File

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

View File

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

View File

@@ -49,4 +49,4 @@ public static class RollBreakdownFormatter
_ => $"{core}={total}"
};
}
}
}

View File

@@ -26,4 +26,4 @@ public sealed class RollEngine
private readonly D6RollEngine m_D6RollEngine;
private readonly RolemasterRollEngine m_RolemasterRollEngine;
private readonly StandardRollEngine m_StandardRollEngine;
}
}

View File

@@ -14,4 +14,4 @@ public static class RollVisibilityParser
return ServiceResult<RollVisibility>.Failure("invalid_visibility", "Visibility must be 'public' or 'private'.");
}
}
}

View File

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

View File

@@ -27,4 +27,4 @@ public sealed class StandardRollEngine
}
private readonly IDiceRoller m_DiceRoller;
}
}