Code cleanup

This commit is contained in:
2026-02-26 11:08:02 +01:00
parent 9036a3a157
commit e7114d8798
72 changed files with 1069 additions and 1604 deletions

View File

@@ -8,8 +8,7 @@ public static class ApiEndpointRegistration
api.MapSystemEndpoints();
api.MapAuthEndpoints();
var authenticatedApi = api.MapGroup(string.Empty)
.AddEndpointFilter<RequireSessionTokenFilter>();
var authenticatedApi = api.MapGroup(string.Empty).AddEndpointFilter<RequireSessionTokenFilter>();
authenticatedApi.MapMeEndpoints();
authenticatedApi.MapCampaignEndpoints();
@@ -17,4 +16,4 @@ public static class ApiEndpointRegistration
authenticatedApi.MapSkillEndpoints();
authenticatedApi.MapStateEventEndpoints();
}
}
}

View File

@@ -9,14 +9,10 @@ internal static class ApiResultMapper
public static Results<Ok<T>, BadRequest<ApiError>, UnauthorizedHttpResult> ToApiResult<T>(ServiceResult<T> result)
{
if (result.Succeeded)
{
return TypedResults.Ok(result.Value!);
}
if (result.Error!.Code == "unauthorized")
{
return TypedResults.Unauthorized();
}
return TypedResults.BadRequest(new ApiError(result.Error.Message));
}
@@ -25,4 +21,4 @@ internal static class ApiResultMapper
{
return TypedResults.BadRequest(new ApiError(error.Message));
}
}
}

View File

@@ -12,9 +12,7 @@ internal static class AuthEndpoints
{
var result = game.Register(request.Username, request.Password, request.DisplayName);
if (!result.Succeeded)
{
return ApiResultMapper.ToBadRequest(result.Error!);
}
return TypedResults.Ok(result.Value!);
});
@@ -23,11 +21,9 @@ internal static class AuthEndpoints
{
var result = game.Login(request.Username, request.Password);
if (!result.Succeeded)
{
return ApiResultMapper.ToBadRequest(result.Error!);
}
context.Response.Cookies.Append(SessionCookie.Name, result.Value.SessionToken, new CookieOptions
context.Response.Cookies.Append(SessionCookie.Name, result.Value.SessionToken, new()
{
HttpOnly = true,
SameSite = SameSiteMode.Strict,
@@ -41,9 +37,7 @@ internal static class AuthEndpoints
group.MapPost("/auth/logout", (HttpContext context, IGameService game) =>
{
if (context.TryReadSessionTokenFromCookie(out var sessionToken))
{
game.Logout(sessionToken);
}
context.Response.Cookies.Delete(SessionCookie.Name);
return TypedResults.NoContent();
@@ -51,4 +45,4 @@ internal static class AuthEndpoints
return group;
}
}
}

View File

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

View File

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

View File

@@ -16,4 +16,4 @@ internal static class MeEndpoints
return group;
}
}
}

View File

@@ -2,4 +2,4 @@ namespace RpgRoller.Api;
internal static class RequestMappings
{
}
}

View File

@@ -5,11 +5,9 @@ internal sealed class RequireSessionTokenFilter : IEndpointFilter
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
if (!context.HttpContext.TryReadSessionTokenFromCookie(out var sessionToken))
{
return ValueTask.FromResult<object?>(TypedResults.Unauthorized());
}
context.HttpContext.StoreSessionToken(sessionToken);
return next(context);
}
}
}

View File

@@ -3,4 +3,4 @@ namespace RpgRoller.Api;
internal static class SessionCookie
{
public const string Name = "rpgroller_session";
}
}

View File

@@ -2,8 +2,6 @@ namespace RpgRoller.Api;
internal static class SessionTokenHttpContextExtensions
{
private const string SessionTokenItemKey = "__rpgroller.session-token";
public static bool TryReadSessionTokenFromCookie(this HttpContext context, out string sessionToken)
{
sessionToken = context.Request.Cookies[SessionCookie.Name] ?? string.Empty;
@@ -17,13 +15,11 @@ internal static class SessionTokenHttpContextExtensions
public static string GetRequiredSessionToken(this HttpContext context)
{
if (context.Items.TryGetValue(SessionTokenItemKey, out var token)
&& token is string sessionToken
&& !string.IsNullOrWhiteSpace(sessionToken))
{
if (context.Items.TryGetValue(SessionTokenItemKey, out var token) && token is string sessionToken && !string.IsNullOrWhiteSpace(sessionToken))
return sessionToken;
}
throw new InvalidOperationException("Session token is not available in this request.");
}
}
private const string SessionTokenItemKey = "__rpgroller.session-token";
}

View File

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

View File

@@ -7,18 +7,13 @@ internal static class StateEventEndpoints
{
public static RouteGroupBuilder MapStateEventEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/events/state", async Task<IResult> (
Guid campaignId,
HttpContext context,
IGameService game) =>
group.MapGet("/events/state", async Task<IResult> (Guid campaignId, HttpContext context, IGameService game) =>
{
var sessionToken = context.GetRequiredSessionToken();
var versionResult = game.GetCampaignVersion(sessionToken, campaignId);
if (!versionResult.Succeeded)
{
return versionResult.Error!.Code == "unauthorized"
? TypedResults.Unauthorized()
: TypedResults.BadRequest(new ApiError(versionResult.Error.Message));
return versionResult.Error!.Code == "unauthorized" ? TypedResults.Unauthorized() : TypedResults.BadRequest(new ApiError(versionResult.Error.Message));
}
context.Response.Headers.CacheControl = "no-cache";
@@ -37,9 +32,7 @@ internal static class StateEventEndpoints
var currentVersionResult = game.GetCampaignVersion(sessionToken, campaignId);
if (!currentVersionResult.Succeeded)
{
break;
}
if (currentVersionResult.Value != lastVersion)
{
@@ -47,9 +40,7 @@ internal static class StateEventEndpoints
await context.Response.WriteAsync($"event: state\ndata: {{\"campaignId\":\"{campaignId}\",\"version\":{lastVersion}}}\n\n");
}
else
{
await context.Response.WriteAsync(": heartbeat\n\n");
}
await context.Response.Body.FlushAsync();
}
@@ -63,4 +54,4 @@ internal static class StateEventEndpoints
return group;
}
}
}

View File

@@ -11,4 +11,4 @@ internal static class SystemEndpoints
group.MapGet("/rulesets", (IGameService game) => TypedResults.Ok(game.GetRulesets()));
return group;
}
}
}

View File

@@ -1,19 +1,18 @@
@using Microsoft.AspNetCore.Components.Web
@attribute [ExcludeFromCodeCoverage]
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<title>RpgRoller</title>
<link rel="stylesheet" href="/styles.css" />
<HeadOutlet @rendermode="InteractiveServer" />
<link rel="stylesheet" href="/styles.css"/>
<HeadOutlet @rendermode="InteractiveServer"/>
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<script src="/js/rpgroller-api.js"></script>
<script src="_framework/blazor.web.js"></script>
<Routes @rendermode="InteractiveServer"/>
<script src="/js/rpgroller-api.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -1,17 +1,16 @@
namespace RpgRoller.Components.Pages;
public sealed class FormState<TModel>
where TModel : new()
public sealed class FormState<TModel> where TModel : new()
{
public TModel Model { get; } = new();
public Dictionary<string, string> Errors { get; } = [];
public string? ErrorMessage { get; set; }
public void ResetValidation()
{
Errors.Clear();
ErrorMessage = null;
}
public TModel Model { get; } = new();
public Dictionary<string, string> Errors { get; } = [];
public string? ErrorMessage { get; set; }
}
public sealed class RegisterFormModel
@@ -52,4 +51,4 @@ public enum HomeViewMode
Loading,
Anonymous,
Workspace
}
}

View File

@@ -17,11 +17,11 @@
<AuthSection
StatusMessage="StatusMessage"
StatusIsError="StatusIsError"
LoggedIn="OnLoggedInAsync" />
LoggedIn="OnLoggedInAsync"/>
</div>
break;
case HomeViewMode.Workspace:
<Workspace LoggedOut="OnLoggedOutAsync" />
<Workspace LoggedOut="OnLoggedOutAsync"/>
break;
}

View File

@@ -1,6 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
@@ -8,20 +7,10 @@ namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public partial class Home
{
private HomeViewMode CurrentView { get; set; } = HomeViewMode.Loading;
private string? StatusMessage { get; set; }
private bool StatusIsError { get; set; }
private bool HasInitialized { get; set; }
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = default!;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender || HasInitialized)
{
return;
}
HasInitialized = true;
await InitializeAsync();
@@ -78,4 +67,12 @@ public partial class Home
StatusMessage = null;
StatusIsError = false;
}
}
private HomeViewMode CurrentView { get; set; } = HomeViewMode.Loading;
private string? StatusMessage { get; set; }
private bool StatusIsError { get; set; }
private bool HasInitialized { get; set; }
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
}

View File

@@ -1,8 +1,3 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
<main class="auth-shell">
<h1>RpgRoller</h1>
<p class="auth-subtitle">Register or log in to join a campaign session.</p>
@@ -19,19 +14,22 @@
}
<form class="form-grid" @onsubmit="SubmitRegisterAsync" @onsubmit:preventDefault>
<label for="register-username">Username</label>
<input id="register-username" @bind="RegisterState.Model.Username" @bind:event="oninput" autocomplete="username" />
<input id="register-username" @bind="RegisterState.Model.Username" @bind:event="oninput"
autocomplete="username"/>
@if (RegisterState.Errors.TryGetValue("username", out var registerUsernameError))
{
<p class="field-error">@registerUsernameError</p>
}
<label for="register-display-name">Display name</label>
<input id="register-display-name" @bind="RegisterState.Model.DisplayName" @bind:event="oninput" autocomplete="name" />
<input id="register-display-name" @bind="RegisterState.Model.DisplayName" @bind:event="oninput"
autocomplete="name"/>
@if (RegisterState.Errors.TryGetValue("displayName", out var registerDisplayNameError))
{
<p class="field-error">@registerDisplayNameError</p>
}
<label for="register-password">Password</label>
<input id="register-password" type="password" @bind="RegisterState.Model.Password" @bind:event="oninput" autocomplete="new-password" />
<input id="register-password" type="password" @bind="RegisterState.Model.Password" @bind:event="oninput"
autocomplete="new-password"/>
@if (RegisterState.Errors.TryGetValue("password", out var registerPasswordError))
{
<p class="field-error">@registerPasswordError</p>
@@ -48,13 +46,15 @@
}
<form class="form-grid" @onsubmit="SubmitLoginAsync" @onsubmit:preventDefault>
<label for="login-username">Username</label>
<input id="login-username" @bind="LoginState.Model.Username" @bind:event="oninput" autocomplete="username" />
<input id="login-username" @bind="LoginState.Model.Username" @bind:event="oninput"
autocomplete="username"/>
@if (LoginState.Errors.TryGetValue("username", out var loginUsernameError))
{
<p class="field-error">@loginUsernameError</p>
}
<label for="login-password">Password</label>
<input id="login-password" type="password" @bind="LoginState.Model.Password" @bind:event="oninput" autocomplete="current-password" />
<input id="login-password" type="password" @bind="LoginState.Model.Password" @bind:event="oninput"
autocomplete="current-password"/>
@if (LoginState.Errors.TryGetValue("password", out var loginPasswordError))
{
<p class="field-error">@loginPasswordError</p>

View File

@@ -1,113 +1,75 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
public partial class AuthSection
{
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = default!;
private FormState<RegisterFormModel> RegisterState { get; } = new();
private FormState<LoginFormModel> LoginState { get; } = new();
private bool IsSubmitting { get; set; }
[Parameter]
public string? StatusMessage { get; set; }
[Parameter]
public bool StatusIsError { get; set; }
[Parameter]
public EventCallback LoggedIn { get; set; }
private async Task SubmitRegisterAsync()
{
RegisterState.ResetValidation();
var model = RegisterState.Model;
if (string.IsNullOrWhiteSpace(model.Username))
{
RegisterState.Errors["username"] = "Username is required.";
}
if (string.IsNullOrWhiteSpace(model.DisplayName))
{
RegisterState.Errors["displayName"] = "Display name is required.";
}
if (string.IsNullOrWhiteSpace(model.Password) || model.Password.Length < 8)
{
RegisterState.Errors["password"] = "Password must be at least 8 characters.";
}
if (RegisterState.Errors.Count > 0)
{
RegisterState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsSubmitting = true;
try
{
_ = await ApiClient.RequestAsync<UserSummary>(
"POST",
"/api/auth/register",
new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim()));
_ = await ApiClient.RequestAsync<UserSummary>("POST", "/api/auth/register", new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim()));
model.Password = string.Empty;
RegisterState.ErrorMessage = "Registration successful. You can log in now.";
}
catch (ApiRequestException ex)
{
if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase))
{
RegisterState.Errors["username"] = "Username is already taken. Choose another one.";
}
else
{
RegisterState.ErrorMessage = ex.Message;
}
}
finally
{
IsSubmitting = false;
}
}
private async Task SubmitLoginAsync()
{
LoginState.ResetValidation();
var model = LoginState.Model;
if (string.IsNullOrWhiteSpace(model.Username))
{
LoginState.Errors["username"] = "Username is required.";
}
if (string.IsNullOrWhiteSpace(model.Password))
{
LoginState.Errors["password"] = "Password is required.";
}
if (LoginState.Errors.Count > 0)
{
LoginState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsSubmitting = true;
try
{
_ = await ApiClient.RequestAsync<UserSummary>(
"POST",
"/api/auth/login",
new LoginRequest(model.Username.Trim(), model.Password));
_ = await ApiClient.RequestAsync<UserSummary>("POST", "/api/auth/login", new LoginRequest(model.Username.Trim(), model.Password));
model.Password = string.Empty;
await LoggedIn.InvokeAsync();
}
@@ -120,4 +82,20 @@ public partial class AuthSection
IsSubmitting = false;
}
}
}
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
private FormState<RegisterFormModel> RegisterState { get; } = new();
private FormState<LoginFormModel> LoginState { get; } = new();
private bool IsSubmitting { get; set; }
[Parameter]
public string? StatusMessage { get; set; }
[Parameter]
public bool StatusIsError { get; set; }
[Parameter]
public EventCallback LoggedIn { get; set; }
}

View File

@@ -1,11 +1,12 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Contracts
<aside class="card log-panel">
<div class="section-head"><h2>Campaign Log</h2></div>
@if (IsCampaignDataLoading)
{
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div>
<div class="skeleton-stack">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
}
else if (CampaignLog.Count == 0)
{
@@ -17,11 +18,16 @@
@foreach (var entry in CampaignLog)
{
<li class="log-entry @LogEntryCssClass(entry)">
<p><strong>@RollerLabel(entry)</strong> rolled <strong>@SkillLabel(entry.SkillId)</strong> with <strong>@CharacterLabel(entry.CharacterId)</strong></p>
<p><strong>@RollerLabel(entry)</strong> rolled <strong>@SkillLabel(entry.SkillId)</strong> with
<strong>@CharacterLabel(entry.CharacterId)</strong></p>
<p class="roll-total inline">@entry.Result</p>
<RollDiceStrip Dice="entry.Dice" AriaLabel="Log roll dice" />
<RollDiceStrip Dice="entry.Dice" AriaLabel="Log roll dice"/>
<p>@entry.Breakdown</p>
<p class="log-meta"><span class="badge @VisibilityBadgeCssClass(entry)">@VisibilityLabel(entry)</span> <time title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time></p>
<p class="log-meta"><span
class="badge @VisibilityBadgeCssClass(entry)">@VisibilityLabel(entry)</span>
<time
title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time>
</p>
</li>
}
</ul>

View File

@@ -1,6 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
namespace RpgRoller.Components.Pages.HomeControls;
@@ -9,25 +9,25 @@ public partial class CampaignLogPanel
{
[Parameter]
public bool IsCampaignDataLoading { get; set; }
[Parameter]
public IReadOnlyList<CampaignLogEntry> CampaignLog { get; set; } = [];
[Parameter]
public Func<CampaignLogEntry, string> RollerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<Guid, string> SkillLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<Guid, string> CharacterLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CampaignLogEntry, string> LogEntryCssClass { get; set; } = _ => string.Empty;
[Parameter]
public Func<CampaignLogEntry, string> VisibilityLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CampaignLogEntry, string> VisibilityBadgeCssClass { get; set; } = _ => string.Empty;
}
}

View File

@@ -1,8 +1,3 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
<main class="management-screen">
<section class="card">
<h2>Campaign Selector</h2>
@@ -16,7 +11,9 @@
<select id="campaign-select" @onchange="CampaignSelectionChanged">
@foreach (var campaign in Campaigns)
{
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)</option>
<option value="@campaign.Id"
selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)
</option>
}
</select>
}
@@ -31,7 +28,7 @@
}
<form class="form-grid" @onsubmit="SubmitCreateCampaignAsync" @onsubmit:preventDefault>
<label for="campaign-name">Campaign name</label>
<input id="campaign-name" @bind="CampaignState.Model.Name" @bind:event="oninput" />
<input id="campaign-name" @bind="CampaignState.Model.Name" @bind:event="oninput"/>
@if (CampaignState.Errors.TryGetValue("name", out var campaignNameError))
{
<p class="field-error">@campaignNameError</p>
@@ -48,7 +45,8 @@
{
<p class="field-error">@campaignRulesetError</p>
}
<button type="submit" disabled="@(IsMutating || IsCreatingCampaign)">@(IsCreatingCampaign ? "Creating..." : "Create Campaign")</button>
<button type="submit"
disabled="@(IsMutating || IsCreatingCampaign)">@(IsCreatingCampaign ? "Creating..." : "Create Campaign")</button>
</form>
</section>
@@ -62,7 +60,8 @@
{
<p>Name: <strong>@SelectedCampaign.Name</strong></p>
<p>Ruleset: <strong>@SelectedCampaign.RulesetId</strong></p>
<p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span class="muted">(@SelectedCampaign.Gm.Username)</span></p>
<p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span
class="muted">(@SelectedCampaign.Gm.Username)</span></p>
<p>Characters visible: <strong>@SelectedCampaign.Characters.Count</strong></p>
}
</section>
@@ -70,7 +69,9 @@
<section class="card">
<div class="section-head">
<h2>Character Management</h2>
<button type="button" disabled="@(IsMutating || IsCreatingCampaign || SelectedCampaign is null)" @onclick="CreateCharacterRequested">Create Character</button>
<button type="button" disabled="@(IsMutating || IsCreatingCampaign || SelectedCampaign is null)"
@onclick="CreateCharacterRequested">Create Character
</button>
</div>
@if (SelectedCampaign is null)
{
@@ -86,9 +87,13 @@
@foreach (var character in SelectedCampaign.Characters)
{
<li>
<div><strong>@character.Name</strong><p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
<div><strong>@character.Name</strong>
<p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))" @onclick="() => EditCharacterRequested.InvokeAsync(character)">Edit</button>
<button type="button"
disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))"
@onclick="() => EditCharacterRequested.InvokeAsync(character)">Edit
</button>
</div>
</li>
}

View File

@@ -1,92 +1,39 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
public partial class CampaignManagementPanel
{
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = default!;
private FormState<CampaignFormModel> CampaignState { get; } = new();
private bool IsCreatingCampaign { get; set; }
[Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
[Parameter]
public Guid? SelectedCampaignId { get; set; }
[Parameter]
public string? SelectedCampaignName { get; set; }
[Parameter]
public CampaignDetails? SelectedCampaign { get; set; }
[Parameter]
public IReadOnlyList<RulesetDefinition> Rulesets { get; set; } = [];
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
[Parameter]
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
[Parameter]
public EventCallback<Guid> CampaignCreated { get; set; }
[Parameter]
public EventCallback CreateCharacterRequested { get; set; }
[Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
protected override void OnParametersSet()
{
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId) && Rulesets.Count > 0)
{
CampaignState.Model.RulesetId = Rulesets[0].Id;
}
}
private async Task SubmitCreateCampaignAsync()
{
CampaignState.ResetValidation();
if (string.IsNullOrWhiteSpace(CampaignState.Model.Name))
{
CampaignState.Errors["name"] = "Campaign name is required.";
}
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId))
{
CampaignState.Errors["rulesetId"] = "Ruleset is required.";
}
if (CampaignState.Errors.Count > 0)
{
CampaignState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsCreatingCampaign = true;
try
{
var campaign = await ApiClient.RequestAsync<CampaignSummary>(
"POST",
"/api/campaigns",
new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId));
var campaign = await ApiClient.RequestAsync<CampaignSummary>("POST", "/api/campaigns", new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId));
CampaignState.Model.Name = string.Empty;
await CampaignCreated.InvokeAsync(campaign.Id);
}
@@ -99,4 +46,46 @@ public partial class CampaignManagementPanel
IsCreatingCampaign = false;
}
}
}
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
private FormState<CampaignFormModel> CampaignState { get; } = new();
private bool IsCreatingCampaign { get; set; }
[Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
[Parameter]
public Guid? SelectedCampaignId { get; set; }
[Parameter]
public string? SelectedCampaignName { get; set; }
[Parameter]
public CampaignDetails? SelectedCampaign { get; set; }
[Parameter]
public IReadOnlyList<RulesetDefinition> Rulesets { get; set; } = [];
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
[Parameter]
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
[Parameter]
public EventCallback<Guid> CampaignCreated { get; set; }
[Parameter]
public EventCallback CreateCharacterRequested { get; set; }
[Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
}

View File

@@ -1,8 +1,3 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
@if (Visible)
{
<div class="modal-overlay" role="presentation">
@@ -14,7 +9,7 @@
}
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
<label for="@NameInputId">Character name</label>
<input id="@NameInputId" @bind="FormState.Model.Name" @bind:event="oninput" />
<input id="@NameInputId" @bind="FormState.Model.Name" @bind:event="oninput"/>
@if (FormState.Errors.TryGetValue("name", out var nameError))
{
<p class="field-error">@nameError</p>

View File

@@ -1,109 +1,52 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
public partial class CharacterFormModal
{
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = default!;
private FormState<CharacterFormModel> FormState { get; } = new();
private int AppliedFormVersion { get; set; } = -1;
private bool IsSubmitting { get; set; }
[Parameter]
public bool Visible { get; set; }
[Parameter]
public string Title { get; set; } = "Character";
[Parameter]
public string SubmitLabel { get; set; } = "Save";
[Parameter]
public string NameInputId { get; set; } = "character-name";
[Parameter]
public string CampaignInputId { get; set; } = "character-campaign";
[Parameter]
public CharacterFormModel InitialModel { get; set; } = new();
[Parameter]
public int FormVersion { get; set; }
[Parameter]
public Guid? EditingCharacterId { get; set; }
[Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public EventCallback<Guid> CharacterSaved { get; set; }
[Parameter]
public EventCallback CancelRequested { get; set; }
protected override void OnParametersSet()
{
if (!Visible || FormVersion == AppliedFormVersion)
{
return;
}
FormState.Model.Name = InitialModel.Name;
FormState.Model.CampaignId = InitialModel.CampaignId;
FormState.ResetValidation();
AppliedFormVersion = FormVersion;
}
private async Task SubmitAsync()
{
FormState.ResetValidation();
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
{
FormState.Errors["name"] = "Character name is required.";
}
if (!Guid.TryParse(FormState.Model.CampaignId, out var campaignId))
{
FormState.Errors["campaignId"] = "Campaign is required.";
}
if (FormState.Errors.Count > 0)
{
FormState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsSubmitting = true;
try
{
CharacterSummary character;
if (EditingCharacterId.HasValue)
{
character = await ApiClient.RequestAsync<CharacterSummary>(
"PUT",
$"/api/characters/{EditingCharacterId.Value}",
new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
character = await ApiClient.RequestAsync<CharacterSummary>("PUT", $"/api/characters/{EditingCharacterId.Value}", new UpdateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
}
else
{
character = await ApiClient.RequestAsync<CharacterSummary>(
"POST",
"/api/characters",
new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
character = await ApiClient.RequestAsync<CharacterSummary>("POST", "/api/characters", new CreateCharacterRequest(FormState.Model.Name.Trim(), campaignId));
}
await CharacterSaved.InvokeAsync(character.CampaignId);
}
catch (ApiRequestException ex)
@@ -115,4 +58,47 @@ public partial class CharacterFormModal
IsSubmitting = false;
}
}
}
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
private FormState<CharacterFormModel> FormState { get; } = new();
private int AppliedFormVersion { get; set; } = -1;
private bool IsSubmitting { get; set; }
[Parameter]
public bool Visible { get; set; }
[Parameter]
public string Title { get; set; } = "Character";
[Parameter]
public string SubmitLabel { get; set; } = "Save";
[Parameter]
public string NameInputId { get; set; } = "character-name";
[Parameter]
public string CampaignInputId { get; set; } = "character-campaign";
[Parameter]
public CharacterFormModel InitialModel { get; set; } = new();
[Parameter]
public int FormVersion { get; set; }
[Parameter]
public Guid? EditingCharacterId { get; set; }
[Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public EventCallback<Guid> CharacterSaved { get; set; }
[Parameter]
public EventCallback CancelRequested { get; set; }
}

View File

@@ -1,12 +1,12 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
<section class="card character-panel">
<div class="section-head"><h2>Character Context</h2></div>
@if (IsCampaignDataLoading)
{
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line short"></div><div class="skeleton-line"></div></div>
<div class="skeleton-stack">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
<div class="skeleton-line"></div>
</div>
}
else if (SelectedCampaign is null)
{
@@ -22,7 +22,8 @@
@foreach (var character in SelectedCampaign.Characters)
{
var isSelectedCharacter = SelectedCharacterId == character.Id;
<button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)" aria-label="@character.Name" @onclick="() => CharacterSelected.InvokeAsync(character.Id)">
<button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)"
aria-label="@character.Name" @onclick="() => CharacterSelected.InvokeAsync(character.Id)">
<span class="icon-tab-glyph">@InitialsFor(character.Name)</span>
<span class="icon-tab-text">@character.Name</span>
</button>
@@ -36,15 +37,22 @@
<p>Campaign: @SelectedCampaign.Name</p>
<span class="badge active">Active</span>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="() => EditCharacterRequested.InvokeAsync(SelectedCharacter)">Edit Character</button>
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="() => EditCharacterRequested.InvokeAsync(SelectedCharacter)">Edit Character
</button>
</div>
</article>
<article class="skills-section">
<div class="section-head">
<h3>Skills</h3>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="OpenCreateSkillModal">Create Skill</button>
<button type="button" disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))" @onclick="OpenEditSkillModal">Edit Skill</button>
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="OpenCreateSkillModal">Create Skill
</button>
<button type="button"
disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))"
@onclick="OpenEditSkillModal">Edit Skill
</button>
</div>
</div>
@if (SelectedCharacterSkills.Count == 0)
@@ -57,7 +65,8 @@
@foreach (var skill in SelectedCharacterSkills)
{
var isSelectedSkill = SelectedSkillId == skill.Id;
<button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)" @onclick="() => SkillSelected.InvokeAsync(skill.Id)">
<button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)"
@onclick="() => SkillSelected.InvokeAsync(skill.Id)">
<strong>@skill.Name</strong><span>@SkillDefinitionLabel(skill)</span>
</button>
}
@@ -69,7 +78,9 @@
<option value="public">Public</option>
<option value="private">Private</option>
</select>
<button type="submit" disabled="@(IsMutating || SelectedSkill is null || !CanRollSkill(SelectedSkill))">Roll Skill</button>
<button type="submit"
disabled="@(IsMutating || SelectedSkill is null || !CanRollSkill(SelectedSkill))">Roll Skill
</button>
</form>
</article>
}
@@ -83,9 +94,13 @@
else
{
<p class="roll-total">@LastRoll.Result</p>
<RollDiceStrip Dice="LastRoll.Dice" AriaLabel="Last roll dice" />
<RollDiceStrip Dice="LastRoll.Dice" AriaLabel="Last roll dice"/>
<p>@LastRoll.Breakdown</p>
<p><span class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span> <time title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time></p>
<p><span
class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span>
<time
title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time>
</p>
}
</article>
</section>
@@ -105,7 +120,7 @@
EditingSkillId="null"
IsMutating="IsMutating"
SkillSaved="OnSkillCreatedAsync"
CancelRequested="CloseSkillModals" />
CancelRequested="CloseSkillModals"/>
<SkillFormModal
Visible="ShowEditSkillModal"
@@ -122,4 +137,4 @@
EditingSkillId="EditingSkillId"
IsMutating="IsMutating"
SkillSaved="OnSkillUpdatedAsync"
CancelRequested="CloseSkillModals" />
CancelRequested="CloseSkillModals"/>

View File

@@ -1,13 +1,86 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
public partial class CharacterPanel
{
private void OpenCreateSkillModal()
{
CreateSkillInitialModel = new()
{
Name = string.Empty,
DiceRollDefinition = string.Empty,
WildDice = IsD6 ? 1 : 0,
AllowFumble = IsD6
};
CreateSkillFormVersion++;
ShowCreateSkillModal = true;
}
private void OpenEditSkillModal()
{
if (SelectedSkill is null)
return;
EditingSkillId = SelectedSkill.Id;
EditSkillInitialModel = new()
{
Name = SelectedSkill.Name,
DiceRollDefinition = SelectedSkill.DiceRollDefinition,
WildDice = SelectedSkill.WildDice,
AllowFumble = SelectedSkill.AllowFumble
};
EditSkillFormVersion++;
ShowEditSkillModal = true;
}
private void CloseSkillModals()
{
ShowCreateSkillModal = false;
ShowEditSkillModal = false;
EditingSkillId = null;
}
private async Task OnSkillCreatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillCreated.InvokeAsync(skillId);
}
private async Task OnSkillUpdatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillUpdated.InvokeAsync(skillId);
}
private async Task OnRollVisibilityChangedAsync(ChangeEventArgs args)
{
var selectedVisibility = args.Value?.ToString() ?? "public";
await RollVisibilityChanged.InvokeAsync(selectedVisibility);
}
private async Task OnRollSubmitAsync()
{
await RollRequested.InvokeAsync();
}
private static string InitialsFor(string value)
{
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (words.Length == 0)
return "?";
if (words.Length == 1)
return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant();
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
}
private bool ShowCreateSkillModal { get; set; }
private bool ShowEditSkillModal { get; set; }
private Guid? EditingSkillId { get; set; }
@@ -15,153 +88,73 @@ public partial class CharacterPanel
private SkillFormModel EditSkillInitialModel { get; set; } = new();
private int CreateSkillFormVersion { get; set; }
private int EditSkillFormVersion { get; set; }
[Parameter]
public bool IsCampaignDataLoading { get; set; }
[Parameter]
public CampaignDetails? SelectedCampaign { get; set; }
[Parameter]
public Guid? SelectedCharacterId { get; set; }
[Parameter]
public CharacterSummary? SelectedCharacter { get; set; }
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public IReadOnlyList<SkillSummary> SelectedCharacterSkills { get; set; } = [];
[Parameter]
public Guid? SelectedSkillId { get; set; }
[Parameter]
public SkillSummary? SelectedSkill { get; set; }
[Parameter]
public bool IsD6 { get; set; }
[Parameter]
public string RollVisibility { get; set; } = "public";
[Parameter]
public EventCallback<string> RollVisibilityChanged { get; set; }
[Parameter]
public RollResult? LastRoll { get; set; }
[Parameter]
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<SkillSummary, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
[Parameter]
public Func<SkillSummary, bool> CanEditSkill { get; set; } = _ => false;
[Parameter]
public Func<SkillSummary, bool> CanRollSkill { get; set; } = _ => false;
[Parameter]
public EventCallback<Guid> CharacterSelected { get; set; }
[Parameter]
public EventCallback<Guid> SkillSelected { get; set; }
[Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
[Parameter]
public EventCallback<Guid> SkillCreated { get; set; }
[Parameter]
public EventCallback<Guid> SkillUpdated { get; set; }
[Parameter]
public EventCallback RollRequested { get; set; }
private void OpenCreateSkillModal()
{
CreateSkillInitialModel = new SkillFormModel
{
Name = string.Empty,
DiceRollDefinition = string.Empty,
WildDice = IsD6 ? 1 : 0,
AllowFumble = IsD6
};
CreateSkillFormVersion++;
ShowCreateSkillModal = true;
}
private void OpenEditSkillModal()
{
if (SelectedSkill is null)
{
return;
}
EditingSkillId = SelectedSkill.Id;
EditSkillInitialModel = new SkillFormModel
{
Name = SelectedSkill.Name,
DiceRollDefinition = SelectedSkill.DiceRollDefinition,
WildDice = SelectedSkill.WildDice,
AllowFumble = SelectedSkill.AllowFumble
};
EditSkillFormVersion++;
ShowEditSkillModal = true;
}
private void CloseSkillModals()
{
ShowCreateSkillModal = false;
ShowEditSkillModal = false;
EditingSkillId = null;
}
private async Task OnSkillCreatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillCreated.InvokeAsync(skillId);
}
private async Task OnSkillUpdatedAsync(Guid skillId)
{
CloseSkillModals();
await SkillUpdated.InvokeAsync(skillId);
}
private async Task OnRollVisibilityChangedAsync(ChangeEventArgs args)
{
var selectedVisibility = args.Value?.ToString() ?? "public";
await RollVisibilityChanged.InvokeAsync(selectedVisibility);
}
private async Task OnRollSubmitAsync()
{
await RollRequested.InvokeAsync();
}
private static string InitialsFor(string value)
{
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (words.Length == 0)
{
return "?";
}
if (words.Length == 1)
{
return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant();
}
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
}
}
}

View File

@@ -1,6 +1,3 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Contracts
@if (Dice.Count > 0)
{
<div class="roll-dice-strip" aria-label="@AriaLabel">

View File

@@ -1,18 +1,12 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
public partial class RollDiceStrip
{
[Parameter]
public IReadOnlyList<RollDieResult> Dice { get; set; } = [];
[Parameter]
public string AriaLabel { get; set; } = "Rolled dice";
private static string RollDieGlyph(int roll)
{
return roll switch
@@ -26,66 +20,52 @@ public partial class RollDiceStrip
_ => roll.ToString()
};
}
private static string RollDieCssClass(RollDieResult die)
{
var classes = new List<string> { "die-chip" };
if (die.Wild)
{
classes.Add("wild");
}
if (die.Crit)
{
classes.Add("crit");
}
if (die.Fumble)
{
classes.Add("fumble");
}
if (die.Removed)
{
classes.Add("removed");
}
if (die.Added)
{
classes.Add("added");
}
return string.Join(" ", classes);
}
private static string RollDieTitle(RollDieResult die)
{
var labels = new List<string> { $"Roll {die.Roll}" };
if (die.Wild)
{
labels.Add("wild");
}
if (die.Crit)
{
labels.Add("critical");
}
if (die.Fumble)
{
labels.Add("fumble");
}
if (die.Removed)
{
labels.Add("removed");
}
if (die.Added)
{
labels.Add("added");
}
return string.Join(", ", labels);
}
}
[Parameter]
public IReadOnlyList<RollDieResult> Dice { get; set; } = [];
[Parameter]
public string AriaLabel { get; set; } = "Rolled dice";
}

View File

@@ -1,8 +1,3 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
@if (Visible)
{
<div class="modal-overlay" role="presentation">
@@ -14,13 +9,13 @@
}
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
<label for="@NameInputId">Skill name</label>
<input id="@NameInputId" @bind="FormState.Model.Name" @bind:event="oninput" />
<input id="@NameInputId" @bind="FormState.Model.Name" @bind:event="oninput"/>
@if (FormState.Errors.TryGetValue("name", out var skillNameError))
{
<p class="field-error">@skillNameError</p>
}
<label for="@ExpressionInputId">Expression</label>
<input id="@ExpressionInputId" @bind="FormState.Model.DiceRollDefinition" @bind:event="oninput" />
<input id="@ExpressionInputId" @bind="FormState.Model.DiceRollDefinition" @bind:event="oninput"/>
@if (FormState.Errors.TryGetValue("diceRollDefinition", out var expressionError))
{
<p class="field-error">@expressionError</p>
@@ -28,13 +23,14 @@
@if (IsD6)
{
<label for="@WildDiceInputId">Wild dice</label>
<input id="@WildDiceInputId" type="number" min="1" step="1" @bind="FormState.Model.WildDice" />
<input id="@WildDiceInputId" type="number" min="1" step="1" @bind="FormState.Model.WildDice"/>
@if (FormState.Errors.TryGetValue("wildDice", out var wildDiceError))
{
<p class="field-error">@wildDiceError</p>
}
<label for="@AllowFumbleInputId">Allow fumble</label>
<input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble" />
<input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble"/>
}
<div class="inline-actions">
<button type="submit" disabled="@(IsMutating || IsSubmitting)">@SubmitLabel</button>

View File

@@ -1,73 +1,17 @@
using Microsoft.AspNetCore.Components;
using RpgRoller.Components.Pages;
using RpgRoller.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
public partial class SkillFormModal
{
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = default!;
private FormState<SkillFormModel> FormState { get; } = new();
private int AppliedFormVersion { get; set; } = -1;
private bool IsSubmitting { get; set; }
[Parameter]
public bool Visible { get; set; }
[Parameter]
public bool IsD6 { get; set; }
[Parameter]
public string Title { get; set; } = "Skill";
[Parameter]
public string SubmitLabel { get; set; } = "Save";
[Parameter]
public string NameInputId { get; set; } = "skill-name";
[Parameter]
public string ExpressionInputId { get; set; } = "skill-expression";
[Parameter]
public string WildDiceInputId { get; set; } = "skill-wild";
[Parameter]
public string AllowFumbleInputId { get; set; } = "skill-fumble";
[Parameter]
public SkillFormModel InitialModel { get; set; } = new();
[Parameter]
public int FormVersion { get; set; }
[Parameter]
public Guid? SelectedCharacterId { get; set; }
[Parameter]
public Guid? EditingSkillId { get; set; }
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public EventCallback<Guid> SkillSaved { get; set; }
[Parameter]
public EventCallback CancelRequested { get; set; }
protected override void OnParametersSet()
{
if (!Visible || FormVersion == AppliedFormVersion)
{
return;
}
FormState.Model.Name = InitialModel.Name;
FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition;
FormState.Model.WildDice = InitialModel.WildDice;
@@ -75,46 +19,33 @@ public partial class SkillFormModal
FormState.ResetValidation();
AppliedFormVersion = FormVersion;
}
private async Task SubmitAsync()
{
FormState.ResetValidation();
if (string.IsNullOrWhiteSpace(FormState.Model.Name))
{
FormState.Errors["name"] = "Skill name is required.";
}
if (string.IsNullOrWhiteSpace(FormState.Model.DiceRollDefinition))
{
FormState.Errors["diceRollDefinition"] = "Expression is required.";
}
if (IsD6 && FormState.Model.WildDice < 1)
{
FormState.Errors["wildDice"] = "D6 skills require at least one wild die.";
}
if (FormState.Errors.Count > 0)
{
FormState.ErrorMessage = "Resolve validation issues before submitting.";
return;
}
IsSubmitting = true;
try
{
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));
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));
}
else
{
@@ -123,17 +54,10 @@ public partial class SkillFormModal
FormState.ErrorMessage = "Select a character first.";
return;
}
skill = await ApiClient.RequestAsync<SkillSummary>(
"POST",
$"/api/characters/{SelectedCharacterId.Value}/skills",
new CreateSkillRequest(
FormState.Model.Name.Trim(),
FormState.Model.DiceRollDefinition.Trim(),
FormState.Model.WildDice,
FormState.Model.AllowFumble));
skill = await ApiClient.RequestAsync<SkillSummary>("POST", $"/api/characters/{SelectedCharacterId.Value}/skills", new CreateSkillRequest(FormState.Model.Name.Trim(), FormState.Model.DiceRollDefinition.Trim(), FormState.Model.WildDice, FormState.Model.AllowFumble));
}
await SkillSaved.InvokeAsync(skill.Id);
}
catch (ApiRequestException ex)
@@ -145,4 +69,56 @@ public partial class SkillFormModal
IsSubmitting = false;
}
}
}
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
private FormState<SkillFormModel> FormState { get; } = new();
private int AppliedFormVersion { get; set; } = -1;
private bool IsSubmitting { get; set; }
[Parameter]
public bool Visible { get; set; }
[Parameter]
public bool IsD6 { get; set; }
[Parameter]
public string Title { get; set; } = "Skill";
[Parameter]
public string SubmitLabel { get; set; } = "Save";
[Parameter]
public string NameInputId { get; set; } = "skill-name";
[Parameter]
public string ExpressionInputId { get; set; } = "skill-expression";
[Parameter]
public string WildDiceInputId { get; set; } = "skill-wild";
[Parameter]
public string AllowFumbleInputId { get; set; } = "skill-fumble";
[Parameter]
public SkillFormModel InitialModel { get; set; } = new();
[Parameter]
public int FormVersion { get; set; }
[Parameter]
public Guid? SelectedCharacterId { get; set; }
[Parameter]
public Guid? EditingSkillId { get; set; }
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public EventCallback<Guid> SkillSaved { get; set; }
[Parameter]
public EventCallback CancelRequested { get; set; }
}

View File

@@ -1,9 +1,4 @@
@using System.Diagnostics.CodeAnalysis
@using Microsoft.JSInterop
@using RpgRoller.Components
@using RpgRoller.Components.Pages.HomeControls
@using RpgRoller.Contracts
<div class="@AppCssClass">
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
@@ -39,8 +34,12 @@
<div class="header-group controls">
<p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p>
<div class="switch-group" role="tablist" aria-label="Screen selector">
<button type="button" class="switch @(CurrentScreen == "play" ? "active" : string.Empty)" @onclick="SwitchToPlayAsync">Play</button>
<button type="button" class="switch @(CurrentScreen == "management" ? "active" : string.Empty)" @onclick="SwitchToManagementAsync">Campaign Management</button>
<button type="button" class="switch @(CurrentScreen == "play" ? "active" : string.Empty)"
@onclick="SwitchToPlayAsync">Play
</button>
<button type="button" class="switch @(CurrentScreen == "management" ? "active" : string.Empty)"
@onclick="SwitchToManagementAsync">Campaign Management
</button>
</div>
<div class="header-actions">
<button type="button" @onclick="ManualRefreshAsync" disabled="@IsMutating">Refresh</button>
@@ -80,7 +79,7 @@
EditCharacterRequested="OpenEditCharacterModal"
SkillCreated="OnSkillCreatedAsync"
SkillUpdated="OnSkillUpdatedAsync"
RollRequested="RollSelectedSkillAsync" />
RollRequested="RollSelectedSkillAsync"/>
<CampaignLogPanel
IsCampaignDataLoading="IsCampaignDataLoading"
@@ -90,11 +89,15 @@
CharacterLabel="CharacterLabel"
LogEntryCssClass="LogEntryCssClass"
VisibilityLabel="VisibilityLabel"
VisibilityBadgeCssClass="VisibilityBadgeCssClass" />
VisibilityBadgeCssClass="VisibilityBadgeCssClass"/>
</main>
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)" @onclick="SetMobilePanelCharacterAsync">Character</button>
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)" @onclick="SetMobilePanelLogAsync">Log</button>
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)"
@onclick="SetMobilePanelCharacterAsync">Character
</button>
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)"
@onclick="SetMobilePanelLogAsync">Log
</button>
</nav>
}
@@ -112,7 +115,7 @@
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
CampaignCreated="OnCampaignCreatedAsync"
CreateCharacterRequested="OpenCreateCharacterModal"
EditCharacterRequested="OpenEditCharacterModal" />
EditCharacterRequested="OpenEditCharacterModal"/>
}
</div>
</div>
@@ -129,7 +132,7 @@
Campaigns="Campaigns"
IsMutating="IsMutating"
CharacterSaved="OnCharacterCreatedAsync"
CancelRequested="CloseCharacterModals" />
CancelRequested="CloseCharacterModals"/>
<CharacterFormModal
Visible="ShowEditCharacterModal"
@@ -143,4 +146,4 @@
Campaigns="Campaigns"
IsMutating="IsMutating"
CharacterSaved="OnCharacterUpdatedAsync"
CancelRequested="CloseCharacterModals" />
CancelRequested="CloseCharacterModals"/>

View File

@@ -1,149 +1,46 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using RpgRoller.Components.Pages.HomeControls;
using RpgRoller.Components;
using RpgRoller.Contracts;
using System.Diagnostics.CodeAnalysis;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public partial class Workspace : IAsyncDisposable
{
[Inject]
private IJSRuntime JS { get; set; } = default!;
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = default!;
private const string ScreenSessionKey = "screen";
private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel";
private UserSummary? User { get; set; }
private Guid? ActiveCharacterId { get; set; }
private Guid? SelectedCampaignId { get; set; }
private CampaignDetails? SelectedCampaign { get; set; }
private List<CampaignSummary> Campaigns { get; set; } = [];
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
private List<RulesetDefinition> Rulesets { get; set; } = [];
private Guid? SelectedCharacterId { get; set; }
private Guid? SelectedSkillId { get; set; }
private RollResult? LastRoll { get; set; }
private string RollVisibility { get; set; } = "public";
private bool IsMutating { get; set; }
private bool IsCampaignDataLoading { get; set; }
private bool HasHealthIssue { get; set; }
private string HealthIssueMessage { get; set; } = "Retry to restore the API connection.";
private string? StatusMessage { get; set; }
private bool StatusIsError { get; set; }
private string CurrentScreen { get; set; } = "play";
private string MobilePanel { get; set; } = "character";
private string ConnectionState { get; set; } = "offline";
private string LiveAnnouncement { get; set; } = string.Empty;
private bool ShowCreateCharacterModal { get; set; }
private bool ShowEditCharacterModal { get; set; }
private Guid? EditingCharacterId { get; set; }
private CharacterFormModel CreateCharacterInitialModel { get; set; } = new();
private CharacterFormModel EditCharacterInitialModel { get; set; } = new();
private int CreateCharacterFormVersion { get; set; }
private int EditCharacterFormVersion { get; set; }
private bool StateRefreshInProgress { get; set; }
private bool HasInteractiveRenderStarted { get; set; }
private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
[Parameter]
public EventCallback<string?> LoggedOut { get; set; }
private string? SelectedCampaignName => SelectedCampaign?.Name;
private CharacterSummary? SelectedCharacter =>
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId);
private SkillSummary? SelectedSkill =>
SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId);
private string? ActiveCharacterName =>
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId)?.Name;
private bool IsCurrentUserGm =>
SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
private bool IsSelectedCampaignD6 =>
string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
private List<SkillSummary> SelectedCharacterSkills =>
SelectedCampaign is null || !SelectedCharacterId.HasValue
? []
: SelectedCampaign.Skills
.Where(skill => skill.CharacterId == SelectedCharacterId.Value)
.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
private bool IsManagementScreen => !IsPlayScreen;
private string ConnectionStateLabel => ConnectionState switch
{
"connected" => "Connected",
"reconnecting" => "Reconnecting",
_ => "Offline fallback"
};
private string ConnectionStateCssClass => ConnectionState switch
{
"connected" => "ok",
"reconnecting" => "warn",
_ => "offline"
};
private string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
HasInteractiveRenderStarted = true;
if (!firstRender)
{
return;
}
await InitializeAsync();
await InvokeAsync(StateHasChanged);
}
private async Task InitializeAsync()
{
var storedScreen = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
if (string.Equals(storedScreen, "management", StringComparison.OrdinalIgnoreCase))
{
CurrentScreen = "management";
}
var storedPanel = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey);
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
{
MobilePanel = "log";
}
Guid? preferredCampaignId = null;
var storedCampaignId = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", CampaignSessionKey);
if (Guid.TryParse(storedCampaignId, out var parsedCampaignId))
{
preferredCampaignId = parsedCampaignId;
}
await CheckHealthAsync();
await LoadRulesetsAsync();
var reloaded = await ReloadAuthenticatedSessionAsync(preferredCampaignId);
if (!reloaded)
{
await LoggedOut.InvokeAsync("Session expired. Please log in again.");
}
}
private async Task RetryAfterHealthIssueAsync()
{
await CheckHealthAsync();
@@ -151,12 +48,10 @@ public partial class Workspace : IAsyncDisposable
{
var reloaded = await ReloadAuthenticatedSessionAsync(SelectedCampaignId);
if (!reloaded)
{
await LoggedOut.InvokeAsync("Session expired. Please log in again.");
}
}
}
private async Task CheckHealthAsync()
{
try
@@ -168,7 +63,7 @@ public partial class Workspace : IAsyncDisposable
HealthIssueMessage = "Health endpoint returned an unhealthy response.";
return;
}
HasHealthIssue = false;
HealthIssueMessage = string.Empty;
}
@@ -178,7 +73,7 @@ public partial class Workspace : IAsyncDisposable
HealthIssueMessage = "Unable to reach API. Retry to continue.";
}
}
private async Task LoadRulesetsAsync()
{
try
@@ -190,7 +85,7 @@ public partial class Workspace : IAsyncDisposable
SetStatus(ex.Message, true);
}
}
private async Task<bool> ReloadAuthenticatedSessionAsync(Guid? preferredCampaignId)
{
var me = await TryGetMeAsync();
@@ -200,16 +95,16 @@ public partial class Workspace : IAsyncDisposable
await StopStateEventsAsync();
return false;
}
User = me.User;
ActiveCharacterId = me.ActiveCharacterId;
await ReloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId);
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
return true;
}
private async Task<MeResponse?> TryGetMeAsync()
{
try
@@ -221,32 +116,28 @@ public partial class Workspace : IAsyncDisposable
return null;
}
}
private async Task ReloadCampaignsAsync(Guid? preferredCampaignId)
{
var campaigns = await ApiClient.RequestAsync<IReadOnlyList<CampaignSummary>>("GET", "/api/campaigns");
Campaigns = campaigns.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList();
if (Campaigns.Count == 0)
{
SelectedCampaignId = null;
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, null);
return;
}
var campaignIds = Campaigns.Select(c => c.Id).ToHashSet();
if (preferredCampaignId.HasValue && campaignIds.Contains(preferredCampaignId.Value))
{
SelectedCampaignId = preferredCampaignId.Value;
}
else if (!SelectedCampaignId.HasValue || !campaignIds.Contains(SelectedCampaignId.Value))
{
SelectedCampaignId = Campaigns[0].Id;
}
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, SelectedCampaignId?.ToString());
}
private async Task RefreshCampaignScopeAsync()
{
if (!SelectedCampaignId.HasValue)
@@ -258,7 +149,7 @@ public partial class Workspace : IAsyncDisposable
ConnectionState = "offline";
return;
}
IsCampaignDataLoading = true;
try
{
@@ -284,14 +175,12 @@ public partial class Workspace : IAsyncDisposable
IsCampaignDataLoading = false;
}
}
private async Task ManualRefreshAsync()
{
if (IsMutating)
{
return;
}
IsMutating = true;
try
{
@@ -302,7 +191,7 @@ public partial class Workspace : IAsyncDisposable
await LoggedOut.InvokeAsync("Session expired. Please log in again.");
return;
}
SetStatus("Campaign data refreshed.", false);
}
finally
@@ -310,14 +199,12 @@ public partial class Workspace : IAsyncDisposable
IsMutating = false;
}
}
private async Task LogoutAsync()
{
if (IsMutating)
{
return;
}
IsMutating = true;
try
{
@@ -330,43 +217,55 @@ public partial class Workspace : IAsyncDisposable
{
IsMutating = false;
}
ClearAuthenticatedState();
await StopStateEventsAsync();
await LoggedOut.InvokeAsync("Logged out.");
}
private async Task SwitchScreenAsync(string screen)
{
CurrentScreen = string.Equals(screen, "management", StringComparison.OrdinalIgnoreCase) ? "management" : "play";
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, CurrentScreen);
}
private Task SwitchToPlayAsync() => SwitchScreenAsync("play");
private Task SwitchToManagementAsync() => SwitchScreenAsync("management");
private Task SwitchToPlayAsync()
{
return SwitchScreenAsync("play");
}
private Task SwitchToManagementAsync()
{
return SwitchScreenAsync("management");
}
private async Task SetMobilePanelAsync(string panel)
{
MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character";
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, MobilePanel);
}
private Task SetMobilePanelCharacterAsync() => SetMobilePanelAsync("character");
private Task SetMobilePanelLogAsync() => SetMobilePanelAsync("log");
private Task SetMobilePanelCharacterAsync()
{
return SetMobilePanelAsync("character");
}
private Task SetMobilePanelLogAsync()
{
return SetMobilePanelAsync("log");
}
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
{
if (!Guid.TryParse(args.Value?.ToString(), out var campaignId))
{
return;
}
SelectedCampaignId = campaignId;
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString());
await RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
}
private async Task OnCampaignCreatedAsync(Guid campaignId)
{
await ReloadCampaignsAsync(campaignId);
@@ -374,39 +273,39 @@ public partial class Workspace : IAsyncDisposable
await SyncStateEventsAsync();
SetStatus("Campaign created.", false);
}
private void OpenCreateCharacterModal()
{
CreateCharacterInitialModel = new CharacterFormModel
CreateCharacterInitialModel = new()
{
Name = string.Empty,
CampaignId = SelectedCampaignId?.ToString() ?? string.Empty
};
CreateCharacterFormVersion++;
ShowCreateCharacterModal = true;
}
private void OpenEditCharacterModal(CharacterSummary character)
{
EditingCharacterId = character.Id;
EditCharacterInitialModel = new CharacterFormModel
EditCharacterInitialModel = new()
{
Name = character.Name,
CampaignId = character.CampaignId.ToString()
};
EditCharacterFormVersion++;
ShowEditCharacterModal = true;
}
private void CloseCharacterModals()
{
ShowCreateCharacterModal = false;
ShowEditCharacterModal = false;
EditingCharacterId = null;
}
private async Task OnCharacterCreatedAsync(Guid campaignId)
{
CloseCharacterModals();
@@ -415,7 +314,7 @@ public partial class Workspace : IAsyncDisposable
await SyncStateEventsAsync();
SetStatus("Character created.", false);
}
private async Task OnCharacterUpdatedAsync(Guid campaignId)
{
CloseCharacterModals();
@@ -424,37 +323,33 @@ public partial class Workspace : IAsyncDisposable
await SyncStateEventsAsync();
SetStatus("Character updated.", false);
}
private async Task SelectCharacterAsync(Guid characterId)
{
SelectedCharacterId = characterId;
SyncSelectedSkill();
await EnsureSelectedCharacterActiveAsync();
}
private bool CanEditCharacter(CharacterSummary character)
{
return User is not null && (character.OwnerUserId == User.Id || IsCurrentUserGm);
}
private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user)
{
return user is not null && character.OwnerUserId == user.Id;
}
private async Task EnsureSelectedCharacterActiveAsync()
{
if (!SelectedCharacterId.HasValue || SelectedCampaign is null)
{
return;
}
var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId.Value);
if (character is null || !CanActivateCharacter(character, User) || ActiveCharacterId == character.Id)
{
return;
}
try
{
await ApiClient.RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate");
@@ -465,20 +360,20 @@ public partial class Workspace : IAsyncDisposable
SetStatus(ex.Message, true);
}
}
private async Task OnSkillCreatedAsync(Guid _)
{
await RefreshCampaignScopeAsync();
SetStatus("Skill created.", false);
}
private async Task OnSkillUpdatedAsync(Guid skillId)
{
SelectedSkillId = skillId;
await RefreshCampaignScopeAsync();
SetStatus("Skill updated.", false);
}
private async Task RollSelectedSkillAsync()
{
if (SelectedSkill is null)
@@ -486,15 +381,12 @@ public partial class Workspace : IAsyncDisposable
SetStatus("Select a skill to roll.", true);
return;
}
IsMutating = true;
try
{
LastRoll = await ApiClient.RequestAsync<RollResult>(
"POST",
$"/api/skills/{SelectedSkill.Id}/roll",
new RollSkillRequest(RollVisibility));
LastRoll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{SelectedSkill.Id}/roll", new RollSkillRequest(RollVisibility));
await RefreshCampaignScopeAsync();
SetStatus("Roll recorded.", false);
Announce("Roll result updated.");
@@ -508,42 +400,38 @@ public partial class Workspace : IAsyncDisposable
IsMutating = false;
}
}
private Task OnRollVisibilityChanged(string visibility)
{
RollVisibility = string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
return Task.CompletedTask;
}
private void SelectSkill(Guid skillId)
{
SelectedSkillId = skillId;
}
private bool CanEditSkill(SkillSummary skill)
{
if (SelectedCampaign is null)
{
return false;
}
var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == skill.CharacterId);
return character is not null && CanEditCharacter(character);
}
private bool CanRollSkill(SkillSummary skill)
{
return CanEditSkill(skill);
}
[JSInvokable]
public async Task OnStateEventReceived(long _)
{
if (StateRefreshInProgress)
{
return;
}
StateRefreshInProgress = true;
try
{
@@ -555,30 +443,26 @@ public partial class Workspace : IAsyncDisposable
await InvokeAsync(StateHasChanged);
}
}
[JSInvokable]
public Task OnConnectionStateChanged(string state)
{
ConnectionState = state switch
{
"connected" => "connected",
"connected" => "connected",
"reconnecting" => "reconnecting",
_ => "offline"
_ => "offline"
};
if (ConnectionState == "reconnecting")
{
Announce("Reconnecting to live updates.");
}
if (ConnectionState == "offline")
{
Announce("Live updates offline. Use manual refresh.");
}
return InvokeAsync(StateHasChanged);
}
private async Task SyncStateEventsAsync()
{
if (User is null || !SelectedCampaignId.HasValue)
@@ -587,19 +471,17 @@ public partial class Workspace : IAsyncDisposable
ConnectionState = "offline";
return;
}
DotNetRef ??= DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("rpgRollerApi.startStateEvents", SelectedCampaignId.Value.ToString(), DotNetRef);
ConnectionState = "reconnecting";
}
private async Task StopStateEventsAsync()
{
if (!HasInteractiveRenderStarted)
{
return;
}
try
{
await JS.InvokeVoidAsync("rpgRollerApi.stopStateEvents");
@@ -611,18 +493,18 @@ public partial class Workspace : IAsyncDisposable
{
}
}
public async ValueTask DisposeAsync()
{
await StopStateEventsAsync();
DotNetRef?.Dispose();
}
private static bool IsStaticRenderInteropException(InvalidOperationException exception)
{
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
}
private void SyncSelectedCharacter()
{
if (SelectedCampaign is null || SelectedCampaign.Characters.Count == 0)
@@ -630,22 +512,20 @@ public partial class Workspace : IAsyncDisposable
SelectedCharacterId = null;
return;
}
var candidateIds = SelectedCampaign.Characters.Select(c => c.Id).ToHashSet();
if (SelectedCharacterId.HasValue && candidateIds.Contains(SelectedCharacterId.Value))
{
return;
}
if (ActiveCharacterId.HasValue && candidateIds.Contains(ActiveCharacterId.Value))
{
SelectedCharacterId = ActiveCharacterId;
return;
}
SelectedCharacterId = SelectedCampaign.Characters[0].Id;
}
private void SyncSelectedSkill()
{
var skills = SelectedCharacterSkills;
@@ -654,111 +534,87 @@ public partial class Workspace : IAsyncDisposable
SelectedSkillId = null;
return;
}
if (SelectedSkillId.HasValue && skills.Any(skill => skill.Id == SelectedSkillId.Value))
{
return;
}
SelectedSkillId = skills[0].Id;
}
private string OwnerLabel(Guid ownerUserId)
{
if (User is not null && ownerUserId == User.Id)
{
return "You";
}
if (SelectedCampaign is not null && ownerUserId == SelectedCampaign.Gm.Id)
{
return $"{SelectedCampaign.Gm.DisplayName} (GM)";
}
return ownerUserId.ToString("N")[..8];
}
private string CharacterLabel(Guid characterId)
{
return SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == characterId)?.Name ?? "Hidden character";
}
private string SkillLabel(Guid skillId)
{
return SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == skillId)?.Name ?? "Hidden skill";
}
private string SkillDefinitionLabel(SkillSummary skill)
{
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{
return skill.DiceRollDefinition;
}
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumbleLabel}";
}
private string RollerLabel(CampaignLogEntry entry)
{
if (User is not null && entry.RollerUserId == User.Id)
{
return "You";
}
if (SelectedCampaign is not null && entry.RollerUserId == SelectedCampaign.Gm.Id)
{
return "GM";
}
return "Participant";
}
private string VisibilityLabel(CampaignLogEntry entry)
{
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
{
return "Public";
}
if (User is not null && entry.RollerUserId == User.Id)
{
return "Private (you)";
}
return IsCurrentUserGm ? "Private (GM view)" : "Private";
}
private string VisibilityBadgeCssClass(CampaignLogEntry entry)
{
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
{
return "public";
}
if (User is not null && entry.RollerUserId == User.Id)
{
return "private-self";
}
return IsCurrentUserGm ? "private-gm" : "private-generic";
}
private string LogEntryCssClass(CampaignLogEntry entry)
{
if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase))
{
return "public";
}
if (User is not null && entry.RollerUserId == User.Id)
{
return "private-self";
}
return IsCurrentUserGm ? "private-gm" : "private-generic";
}
private void ClearAuthenticatedState()
{
User = null;
@@ -777,16 +633,102 @@ public partial class Workspace : IAsyncDisposable
CreateCharacterFormVersion = 0;
EditCharacterFormVersion = 0;
}
private void SetStatus(string message, bool isError)
{
StatusMessage = message;
StatusIsError = isError;
Announce(message);
}
private void Announce(string message)
{
LiveAnnouncement = message;
}
}
[Inject]
private IJSRuntime JS { get; set; } = null!;
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
private UserSummary? User { get; set; }
private Guid? ActiveCharacterId { get; set; }
private Guid? SelectedCampaignId { get; set; }
private CampaignDetails? SelectedCampaign { get; set; }
private List<CampaignSummary> Campaigns { get; set; } = [];
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
private List<RulesetDefinition> Rulesets { get; set; } = [];
private Guid? SelectedCharacterId { get; set; }
private Guid? SelectedSkillId { get; set; }
private RollResult? LastRoll { get; set; }
private string RollVisibility { get; set; } = "public";
private bool IsMutating { get; set; }
private bool IsCampaignDataLoading { get; set; }
private bool HasHealthIssue { get; set; }
private string HealthIssueMessage { get; set; } = "Retry to restore the API connection.";
private string? StatusMessage { get; set; }
private bool StatusIsError { get; set; }
private string CurrentScreen { get; set; } = "play";
private string MobilePanel { get; set; } = "character";
private string ConnectionState { get; set; } = "offline";
private string LiveAnnouncement { get; set; } = string.Empty;
private bool ShowCreateCharacterModal { get; set; }
private bool ShowEditCharacterModal { get; set; }
private Guid? EditingCharacterId { get; set; }
private CharacterFormModel CreateCharacterInitialModel { get; set; } = new();
private CharacterFormModel EditCharacterInitialModel { get; set; } = new();
private int CreateCharacterFormVersion { get; set; }
private int EditCharacterFormVersion { get; set; }
private bool StateRefreshInProgress { get; set; }
private bool HasInteractiveRenderStarted { get; set; }
private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
[Parameter]
public EventCallback<string?> LoggedOut { get; set; }
private string? SelectedCampaignName => SelectedCampaign?.Name;
private CharacterSummary? SelectedCharacter =>
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId);
private SkillSummary? SelectedSkill =>
SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId);
private string? ActiveCharacterName =>
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId)?.Name;
private bool IsCurrentUserGm =>
SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
private bool IsSelectedCampaignD6 =>
string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
private List<SkillSummary> SelectedCharacterSkills =>
SelectedCampaign is null || !SelectedCharacterId.HasValue ? [] : SelectedCampaign.Skills.Where(skill => skill.CharacterId == SelectedCharacterId.Value).OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
private bool IsManagementScreen => !IsPlayScreen;
private string ConnectionStateLabel => ConnectionState switch
{
"connected" => "Connected",
"reconnecting" => "Reconnecting",
_ => "Offline fallback"
};
private string ConnectionStateCssClass => ConnectionState switch
{
"connected" => "ok",
"reconnecting" => "warn",
_ => "offline"
};
private string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app";
private const string ScreenSessionKey = "screen";
private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel";
}

View File

@@ -1,8 +1,9 @@
@using RpgRoller.Components.Layout
@attribute [ExcludeFromCodeCoverage]
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
<FocusOnNavigate RouteData="@routeData" Selector="h1"/>
</Found>
</Router>

View File

@@ -5,8 +5,13 @@ namespace RpgRoller.Components;
public sealed class RpgRollerApiClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IJSRuntime m_Js;
private sealed class JsApiResponse
{
public bool Ok { get; set; }
public int Status { get; set; }
public string? Error { get; set; }
public JsonElement Data { get; set; }
}
public RpgRollerApiClient(IJSRuntime js)
{
@@ -17,14 +22,10 @@ public sealed class RpgRollerApiClient
{
var response = await m_Js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, payload);
if (!response.Ok)
{
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
}
if (response.Data.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
{
return default!;
}
return response.Data.Deserialize<T>(JsonOptions)!;
}
@@ -33,27 +34,19 @@ public sealed class RpgRollerApiClient
{
var response = await m_Js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, null);
if (!response.Ok)
{
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
}
}
private sealed class JsApiResponse
{
public bool Ok { get; set; }
public int Status { get; set; }
public string? Error { get; set; }
public JsonElement Data { get; set; }
}
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IJSRuntime m_Js;
}
public sealed class ApiRequestException : Exception
{
public ApiRequestException(int statusCode, string message)
: base(message)
public ApiRequestException(int statusCode, string message) : base(message)
{
StatusCode = statusCode;
}
public int StatusCode { get; }
}
}

View File

@@ -1,56 +1,41 @@
namespace RpgRoller.Contracts;
public sealed record HealthResponse(string Status);
public sealed record ApiError(string Error);
public sealed record RegisterRequest(string Username, string Password, string DisplayName);
public sealed record LoginRequest(string Username, string Password);
public sealed record UserSummary(Guid Id, string Username, string DisplayName);
public sealed record MeResponse(UserSummary User, Guid? ActiveCharacterId, Guid? CurrentCampaignId);
public sealed record RulesetDefinition(string Id, string Name, string DiceSyntax);
public sealed record CreateCampaignRequest(string Name, string RulesetId);
public sealed record CampaignSummary(Guid Id, string Name, string RulesetId, Guid GmUserId);
public sealed record CampaignDetails(
Guid Id,
string Name,
string RulesetId,
UserSummary Gm,
IReadOnlyList<CharacterSummary> Characters,
IReadOnlyList<SkillSummary> Skills);
public sealed record CampaignDetails(Guid Id, string Name, string RulesetId, UserSummary Gm, IReadOnlyList<CharacterSummary> Characters, IReadOnlyList<SkillSummary> Skills);
public sealed record CreateCharacterRequest(string Name, Guid CampaignId);
public sealed record UpdateCharacterRequest(string Name, Guid CampaignId);
public sealed record CharacterSummary(Guid Id, string Name, Guid OwnerUserId, Guid CampaignId);
public sealed record CreateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record UpdateSkillRequest(string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record SkillSummary(Guid Id, Guid CharacterId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble);
public sealed record RollSkillRequest(string Visibility);
public sealed record RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild, bool Removed, bool Added);
public sealed record RollResult(
Guid RollId,
Guid CampaignId,
Guid CharacterId,
Guid SkillId,
Guid RollerUserId,
string Visibility,
int Result,
string Breakdown,
IReadOnlyList<RollDieResult> Dice,
DateTimeOffset TimestampUtc);
public sealed record CampaignLogEntry(
Guid RollId,
Guid CampaignId,
Guid CharacterId,
Guid SkillId,
Guid RollerUserId,
string Visibility,
int Result,
string Breakdown,
IReadOnlyList<RollDieResult> Dice,
DateTimeOffset TimestampUtc);
public sealed record RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild, bool Removed, bool Added);
public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);
public sealed record CampaignLogEntry(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);

View File

@@ -5,18 +5,10 @@ namespace RpgRoller.Data;
public sealed class RpgRollerDbContext : DbContext
{
public RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> options)
: base(options)
public RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> options) : base(options)
{
}
public DbSet<UserAccount> Users => Set<UserAccount>();
public DbSet<UserSession> Sessions => Set<UserSession>();
public DbSet<Campaign> Campaigns => Set<Campaign>();
public DbSet<Character> Characters => Set<Character>();
public DbSet<Skill> Skills => Set<Skill>();
public DbSet<RollLogEntry> RollLogEntries => Set<RollLogEntry>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<UserAccount>(entity =>
@@ -77,4 +69,11 @@ public sealed class RpgRollerDbContext : DbContext
entity.HasIndex(x => x.CharacterId);
});
}
}
public DbSet<UserAccount> Users => Set<UserAccount>();
public DbSet<UserSession> Sessions => Set<UserSession>();
public DbSet<Campaign> Campaigns => Set<Campaign>();
public DbSet<Character> Characters => Set<Character>();
public DbSet<Skill> Skills => Set<Skill>();
public DbSet<RollLogEntry> RollLogEntries => Set<RollLogEntry>();
}

View File

@@ -70,4 +70,4 @@ public sealed class RollLogEntry
public required DateTimeOffset TimestampUtc { get; init; }
}
public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical);
public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical);

View File

@@ -14,4 +14,4 @@ public static class ApplicationInitializationExtensions
SqliteSchemaUpgrader.ApplyPendingChanges(db);
_ = scope.ServiceProvider.GetRequiredService<IGameService>();
}
}
}

View File

@@ -9,17 +9,13 @@ namespace RpgRoller.Hosting;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddRpgRollerCore(
this IServiceCollection services,
IConfiguration configuration,
IWebHostEnvironment environment)
public static IServiceCollection AddRpgRollerCore(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment environment)
{
var sqliteConnectionString = configuration.GetConnectionString("RpgRoller") ?? "Data Source=App_Data/rpgroller.db";
EnsureSqliteDataDirectory(sqliteConnectionString, environment.ContentRootPath);
services.AddSingleton<IPasswordHasher<UserAccount>, PasswordHasher<UserAccount>>();
services.AddDbContextFactory<RpgRollerDbContext>(options =>
options.UseSqlite(sqliteConnectionString));
services.AddDbContextFactory<RpgRollerDbContext>(options => options.UseSqlite(sqliteConnectionString));
services.AddSingleton<IDiceRoller, RandomDiceRoller>();
services.AddSingleton<IGameService, GameService>();
@@ -30,18 +26,12 @@ public static class ServiceCollectionExtensions
{
var builder = new SqliteConnectionStringBuilder(connectionString);
if (string.IsNullOrWhiteSpace(builder.DataSource) || builder.DataSource == ":memory:")
{
return;
}
var fullPath = Path.IsPathRooted(builder.DataSource)
? builder.DataSource
: Path.Combine(contentRootPath, builder.DataSource);
var fullPath = Path.IsPathRooted(builder.DataSource) ? builder.DataSource : Path.Combine(contentRootPath, builder.DataSource);
var directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
}
}
}

View File

@@ -5,15 +5,10 @@ namespace RpgRoller.Hosting;
public static class SqliteSchemaUpgrader
{
private const string InitialMigrationId = "20260226084000_InitialSchema";
private const string ProductVersion = "10.0.2";
public static void ApplyPendingChanges(RpgRollerDbContext db)
{
if (db.Database.IsSqlite())
{
EnsureLegacySchemaHistory(db);
}
db.Database.Migrate();
}
@@ -24,36 +19,28 @@ public static class SqliteSchemaUpgrader
try
{
if (TableExists(db, "__EFMigrationsHistory"))
{
return;
}
if (!TableExists(db, "Skills"))
{
return;
}
if (!ColumnExists(db, "Skills", "WildDice") || !ColumnExists(db, "Skills", "AllowFumble"))
{
return;
}
using var createHistoryCommand = db.Database.GetDbConnection().CreateCommand();
createHistoryCommand.CommandText =
"""
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
);
""";
createHistoryCommand.CommandText = """
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
"ProductVersion" TEXT NOT NULL
);
""";
_ = createHistoryCommand.ExecuteNonQuery();
using var insertHistoryCommand = db.Database.GetDbConnection().CreateCommand();
insertHistoryCommand.CommandText =
"""
INSERT OR IGNORE INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ($migrationId, $productVersion);
""";
insertHistoryCommand.CommandText = """
INSERT OR IGNORE INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ($migrationId, $productVersion);
""";
var migrationParameter = insertHistoryCommand.CreateParameter();
migrationParameter.ParameterName = "$migrationId";
@@ -94,11 +81,12 @@ public static class SqliteSchemaUpgrader
{
var currentColumnName = reader.GetString(1);
if (string.Equals(currentColumnName, columnName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
private const string InitialMigrationId = "20260226084000_InitialSchema";
private const string ProductVersion = "10.0.2";
}

View File

@@ -1,11 +1,11 @@
using RpgRoller.Api;
using RpgRoller.Components;
using RpgRoller.Hosting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRpgRollerCore(builder.Configuration, builder.Environment);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddScoped<RpgRoller.Components.RpgRollerApiClient>();
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddScoped<RpgRollerApiClient>();
var app = builder.Build();
app.InitializeRpgRollerState();
@@ -14,8 +14,7 @@ app.UseStaticFiles();
app.UseAntiforgery();
app.MapRpgRollerApi();
app.MapRazorComponents<RpgRoller.Components.App>()
.AddInteractiveServerRenderMode();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
app.Run();
public partial class Program;
public partial class Program;

View File

@@ -1,17 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2"/>
</ItemGroup>
</Project>

View File

@@ -5,27 +5,13 @@ namespace RpgRoller.Services;
public static partial class DiceRules
{
private const int MaxDiceCount = 50;
private const int MaxSides = 1000;
private const int MaxModifier = 1000;
public static readonly IReadOnlyList<(RulesetKind Kind, string Id, string Name, string DiceSyntax)> SupportedRulesets =
[
(RulesetKind.D6, "d6", "D6 System", "countD(+modifier), e.g. 5D+4"),
(RulesetKind.Dnd5e, "dnd5e", "D&D 5e", "countdSides(+modifier), e.g. 2d12+2")
];
public static RulesetKind? TryParseRulesetId(string rulesetId)
{
if (string.Equals(rulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{
return RulesetKind.D6;
}
if (string.Equals(rulesetId, "dnd5e", StringComparison.OrdinalIgnoreCase))
{
return RulesetKind.Dnd5e;
}
return null;
}
@@ -34,25 +20,23 @@ public static partial class DiceRules
{
return ruleset switch
{
RulesetKind.D6 => "d6",
RulesetKind.D6 => "d6",
RulesetKind.Dnd5e => "dnd5e",
_ => throw new ArgumentOutOfRangeException(nameof(ruleset), ruleset, "Unknown ruleset.")
_ => throw new ArgumentOutOfRangeException(nameof(ruleset), ruleset, "Unknown ruleset.")
};
}
public static ServiceResult<DiceExpression> ParseExpression(RulesetKind ruleset, string expression)
{
if (string.IsNullOrWhiteSpace(expression))
{
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Dice expression is required.");
}
var trimmed = expression.Trim();
return ruleset switch
{
RulesetKind.D6 => ParseD6(trimmed),
RulesetKind.D6 => ParseD6(trimmed),
RulesetKind.Dnd5e => ParseDnd5e(trimmed),
_ => ServiceResult<DiceExpression>.Failure("invalid_ruleset", "Unknown ruleset.")
_ => ServiceResult<DiceExpression>.Failure("invalid_ruleset", "Unknown ruleset.")
};
}
@@ -60,57 +44,43 @@ public static partial class DiceRules
{
var match = D6Regex().Match(expression);
if (!match.Success)
{
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected d6 format like 5D+4.");
}
var diceCount = int.Parse(match.Groups["count"].Value);
var modifier = ParseModifier(match.Groups["modifier"].Value);
var validation = ValidateDiceParts(diceCount, 6, modifier);
if (!validation.Succeeded)
{
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
}
return ServiceResult<DiceExpression>.Success(new DiceExpression(diceCount, 6, modifier, $"{diceCount}D{FormatModifier(modifier)}"));
return ServiceResult<DiceExpression>.Success(new(diceCount, 6, modifier, $"{diceCount}D{FormatModifier(modifier)}"));
}
private static ServiceResult<DiceExpression> ParseDnd5e(string expression)
{
var match = Dnd5eRegex().Match(expression);
if (!match.Success)
{
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected dnd5e format like 2d12+2.");
}
var diceCount = int.Parse(match.Groups["count"].Value);
var sides = int.Parse(match.Groups["sides"].Value);
var modifier = ParseModifier(match.Groups["modifier"].Value);
var validation = ValidateDiceParts(diceCount, sides, modifier);
if (!validation.Succeeded)
{
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
}
return ServiceResult<DiceExpression>.Success(new DiceExpression(diceCount, sides, modifier, $"{diceCount}d{sides}{FormatModifier(modifier)}"));
return ServiceResult<DiceExpression>.Success(new(diceCount, sides, modifier, $"{diceCount}d{sides}{FormatModifier(modifier)}"));
}
private static ServiceResult<bool> ValidateDiceParts(int diceCount, int sides, int modifier)
{
if (diceCount < 1 || diceCount > MaxDiceCount)
{
return ServiceResult<bool>.Failure("invalid_expression", $"Dice count must be between 1 and {MaxDiceCount}.");
}
if (sides < 2 || sides > MaxSides)
{
return ServiceResult<bool>.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}.");
}
if (modifier < 0 || modifier > MaxModifier)
{
return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between 0 and {MaxModifier}.");
}
return ServiceResult<bool>.Success(true);
}
@@ -130,4 +100,14 @@ public static partial class DiceRules
[GeneratedRegex("^(?<count>\\d+)d(?<sides>\\d+)(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
private static partial Regex Dnd5eRegex();
}
private const int MaxDiceCount = 50;
private const int MaxSides = 1000;
private const int MaxModifier = 1000;
public static readonly IReadOnlyList<(RulesetKind Kind, string Id, string Name, string DiceSyntax)> SupportedRulesets =
[
(RulesetKind.D6, "d6", "D6 System", "countD(+modifier), e.g. 5D+4"),
(RulesetKind.Dnd5e, "dnd5e", "D&D 5e", "countdSides(+modifier), e.g. 2d12+2")
];
}

View File

@@ -1,6 +1,6 @@
using System.Text.Json;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using RpgRoller.Contracts;
using RpgRoller.Data;
using RpgRoller.Domain;
@@ -9,19 +9,6 @@ namespace RpgRoller.Services;
public sealed class GameService : IGameService
{
private static readonly JsonSerializerOptions DiceJsonOptions = new(JsonSerializerDefaults.Web);
private readonly object m_Gate = new();
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
private readonly IDiceRoller m_DiceRoller;
private readonly Dictionary<Guid, UserAccount> m_UsersById = [];
private readonly Dictionary<string, Guid> m_UserIdsByUsername = new(StringComparer.Ordinal);
private readonly Dictionary<string, UserSession> m_SessionsByToken = new(StringComparer.Ordinal);
private readonly Dictionary<Guid, Campaign> m_CampaignsById = [];
private readonly Dictionary<Guid, Character> m_CharactersById = [];
private readonly Dictionary<Guid, Skill> m_SkillsById = [];
private readonly List<RollLogEntry> m_RollLog = [];
public GameService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller)
{
m_DbContextFactory = dbContextFactory;
@@ -32,36 +19,26 @@ public sealed class GameService : IGameService
public IReadOnlyList<RulesetDefinition> GetRulesets()
{
return DiceRules.SupportedRulesets
.Select(r => new RulesetDefinition(r.Id, r.Name, r.DiceSyntax))
.ToArray();
return DiceRules.SupportedRulesets.Select(r => new RulesetDefinition(r.Id, r.Name, r.DiceSyntax)).ToArray();
}
public ServiceResult<UserSummary> Register(string username, string password, string displayName)
{
if (string.IsNullOrWhiteSpace(username))
{
return ServiceResult<UserSummary>.Failure("invalid_username", "Username is required.");
}
if (string.IsNullOrWhiteSpace(displayName))
{
return ServiceResult<UserSummary>.Failure("invalid_display_name", "Display name is required.");
}
if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
{
return ServiceResult<UserSummary>.Failure("invalid_password", "Password must be at least 8 characters.");
}
lock (m_Gate)
{
var trimmedUsername = username.Trim();
var normalizedUsername = NormalizeUsername(trimmedUsername);
if (m_UserIdsByUsername.ContainsKey(normalizedUsername))
{
return ServiceResult<UserSummary>.Failure("duplicate_username", "Username is already taken.");
}
var user = new UserAccount
{
@@ -86,29 +63,21 @@ public sealed class GameService : IGameService
public ServiceResult<(UserSummary User, string SessionToken)> Login(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
}
lock (m_Gate)
{
var normalizedUsername = NormalizeUsername(username.Trim());
if (!m_UserIdsByUsername.TryGetValue(normalizedUsername, out var userId))
{
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
}
var user = m_UsersById[userId];
var verification = m_PasswordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verification == PasswordVerificationResult.Failed)
{
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
}
if (verification == PasswordVerificationResult.SuccessRehashNeeded)
{
user.PasswordHash = m_PasswordHasher.HashPassword(user, password);
}
var session = CreateSession(userId);
PersistStateLocked();
@@ -121,9 +90,7 @@ public sealed class GameService : IGameService
lock (m_Gate)
{
if (m_SessionsByToken.Remove(sessionToken))
{
PersistStateLocked();
}
}
}
@@ -142,9 +109,7 @@ public sealed class GameService : IGameService
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.");
}
Guid? campaignId = null;
if (user.ActiveCharacterId is Guid activeCharacterId)
@@ -155,35 +120,27 @@ public sealed class GameService : IGameService
PersistStateLocked();
}
else
{
campaignId = activeCharacter.CampaignId;
}
}
return ServiceResult<MeResponse>.Success(new MeResponse(ToUserSummary(user), user.ActiveCharacterId, campaignId));
return ServiceResult<MeResponse>.Success(new(ToUserSummary(user), user.ActiveCharacterId, campaignId));
}
}
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
{
if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<CampaignSummary>.Failure("invalid_campaign_name", "Campaign name is required.");
}
var ruleset = DiceRules.TryParseRulesetId(rulesetId);
if (ruleset is null)
{
return ServiceResult<CampaignSummary>.Failure("invalid_ruleset", "Unknown ruleset.");
}
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<CampaignSummary>.Failure("unauthorized", "You must be logged in.");
}
var campaign = new Campaign
{
@@ -206,21 +163,13 @@ public sealed class GameService : IGameService
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unauthorized", "You must be logged in.");
}
var campaignIds = new HashSet<Guid>(m_CampaignsById.Values.Where(c => c.GmUserId == user.Id).Select(c => c.Id));
foreach (var character in m_CharactersById.Values.Where(c => c.OwnerUserId == user.Id))
{
campaignIds.Add(character.CampaignId);
}
var results = campaignIds
.Select(id => m_CampaignsById[id])
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCampaignSummary)
.ToArray();
var results = campaignIds.Select(id => m_CampaignsById[id]).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCampaignSummary).ToArray();
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results);
}
@@ -232,58 +181,35 @@ public sealed class GameService : IGameService
{
var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded)
{
return ServiceResult<CampaignDetails>.Failure(context.Error!.Code, context.Error.Message);
}
var (user, campaign) = context.Value!;
var gm = m_UsersById[campaign.GmUserId];
var isGm = campaign.GmUserId == user.Id;
var characters = m_CharactersById.Values
.Where(c => c.CampaignId == campaign.Id)
.Where(c => isGm || c.OwnerUserId == user.Id)
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSummary)
.ToArray();
var characters = m_CharactersById.Values.Where(c => c.CampaignId == campaign.Id).Where(c => isGm || c.OwnerUserId == user.Id).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCharacterSummary).ToArray();
var visibleCharacterIds = characters.Select(c => c.Id).ToHashSet();
var skills = m_SkillsById.Values
.Where(s => visibleCharacterIds.Contains(s.CharacterId))
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToSkillSummary)
.ToArray();
var skills = m_SkillsById.Values.Where(s => visibleCharacterIds.Contains(s.CharacterId)).OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase).Select(ToSkillSummary).ToArray();
return ServiceResult<CampaignDetails>.Success(new CampaignDetails(
campaign.Id,
campaign.Name,
DiceRules.ToRulesetId(campaign.Ruleset),
ToUserSummary(gm),
characters,
skills));
return ServiceResult<CampaignDetails>.Success(new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), ToUserSummary(gm), characters, skills));
}
}
public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
{
if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
}
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
}
if (!m_CampaignsById.ContainsKey(campaignId))
{
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
}
var character = new Character
{
@@ -304,36 +230,26 @@ public sealed class GameService : IGameService
public ServiceResult<CharacterSummary> UpdateCharacter(string sessionToken, Guid characterId, string name, Guid campaignId)
{
if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
}
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
}
if (!m_CharactersById.TryGetValue(characterId, out var character))
{
return ServiceResult<CharacterSummary>.Failure("character_not_found", "Character was not found.");
}
if (!m_CampaignsById.TryGetValue(campaignId, out var targetCampaign))
{
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
}
var sourceCampaign = m_CampaignsById[character.CampaignId];
var isOwner = character.OwnerUserId == user.Id;
var isSourceGm = sourceCampaign.GmUserId == user.Id;
var isTargetGm = targetCampaign.GmUserId == user.Id;
if (!isOwner && !isSourceGm && !isTargetGm)
{
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner or GM can edit this character.");
}
var sourceCampaignId = character.CampaignId;
character.Name = name.Trim();
@@ -341,9 +257,7 @@ public sealed class GameService : IGameService
TouchCampaignLocked(sourceCampaignId);
if (sourceCampaignId != character.CampaignId)
{
TouchCampaignLocked(character.CampaignId);
}
PersistStateLocked();
return ServiceResult<CharacterSummary>.Success(ToCharacterSummary(character));
@@ -356,19 +270,13 @@ public sealed class GameService : IGameService
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
}
if (!m_CharactersById.TryGetValue(characterId, out var character))
{
return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
}
if (character.OwnerUserId != user.Id)
{
return ServiceResult<bool>.Failure("forbidden", "You can activate only your own character.");
}
user.ActiveCharacterId = character.Id;
PersistStateLocked();
@@ -382,20 +290,12 @@ public sealed class GameService : IGameService
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
}
if (!TryGetCurrentCampaignIdLocked(user, out var campaignId))
{
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("no_active_character", "No active character is selected.");
}
var characters = m_CharactersById.Values
.Where(c => c.CampaignId == campaignId && c.OwnerUserId == user.Id)
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.Select(ToCharacterSummary)
.ToArray();
var characters = m_CharactersById.Values.Where(c => c.CampaignId == campaignId && c.OwnerUserId == user.Id).OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).Select(ToCharacterSummary).ToArray();
return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
}
@@ -404,40 +304,28 @@ public sealed class GameService : IGameService
public ServiceResult<SkillSummary> CreateSkill(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble)
{
if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
}
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
}
if (!m_CharactersById.TryGetValue(characterId, out var character))
{
return ServiceResult<SkillSummary>.Failure("character_not_found", "Character was not found.");
}
var campaign = m_CampaignsById[character.CampaignId];
if (!CanEditCharacterLocked(user.Id, character, campaign))
{
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
}
var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, diceRollDefinition);
if (!expressionValidation.Succeeded)
{
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
}
var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble);
if (!optionsValidation.Succeeded)
{
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
}
var skill = new Skill
{
@@ -460,41 +348,29 @@ public sealed class GameService : IGameService
public ServiceResult<SkillSummary> UpdateSkill(string sessionToken, Guid skillId, string name, string diceRollDefinition, int wildDice, bool allowFumble)
{
if (string.IsNullOrWhiteSpace(name))
{
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
}
lock (m_Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
}
if (!m_SkillsById.TryGetValue(skillId, out var skill))
{
return ServiceResult<SkillSummary>.Failure("skill_not_found", "Skill was not found.");
}
var character = m_CharactersById[skill.CharacterId];
var campaign = m_CampaignsById[character.CampaignId];
if (!CanEditCharacterLocked(user.Id, character, campaign))
{
return ServiceResult<SkillSummary>.Failure("forbidden", "Only the owner or GM can edit skills.");
}
var expressionValidation = DiceRules.ParseExpression(campaign.Ruleset, diceRollDefinition);
if (!expressionValidation.Succeeded)
{
return ServiceResult<SkillSummary>.Failure(expressionValidation.Error!.Code, expressionValidation.Error.Message);
}
var optionsValidation = ValidateSkillOptions(campaign.Ruleset, wildDice, allowFumble);
if (!optionsValidation.Succeeded)
{
return ServiceResult<SkillSummary>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
}
skill.Name = name.Trim();
skill.DiceRollDefinition = expressionValidation.Value!.Canonical;
@@ -513,33 +389,23 @@ public sealed class GameService : IGameService
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
}
if (!m_SkillsById.TryGetValue(skillId, out var skill))
{
return ServiceResult<RollResult>.Failure("skill_not_found", "Skill was not found.");
}
var character = m_CharactersById[skill.CharacterId];
var campaign = m_CampaignsById[character.CampaignId];
if (!CanEditCharacterLocked(user.Id, character, campaign))
{
return ServiceResult<RollResult>.Failure("forbidden", "Only the owner or GM can roll this skill.");
}
var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, skill.DiceRollDefinition);
if (!parsedExpression.Succeeded)
{
return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
}
var parsedVisibility = ParseVisibility(visibility);
if (!parsedVisibility.Succeeded)
{
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
}
var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, skill);
var entry = new RollLogEntry
@@ -570,18 +436,10 @@ public sealed class GameService : IGameService
{
var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded)
{
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Failure(context.Error!.Code, context.Error.Message);
}
var (user, campaign) = context.Value!;
var entries = m_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)
.Select(ToLogEntry)
.ToArray();
var entries = m_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).Select(ToLogEntry).ToArray();
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries);
}
@@ -593,9 +451,7 @@ public sealed class GameService : IGameService
{
var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded)
{
return ServiceResult<long>.Failure(context.Error!.Code, context.Error.Message);
}
return ServiceResult<long>.Success(context.Value!.Campaign.Version);
}
@@ -604,16 +460,12 @@ public sealed class GameService : IGameService
private static ServiceResult<(int WildDice, bool AllowFumble)> ValidateSkillOptions(RulesetKind ruleset, int wildDice, bool allowFumble)
{
if (wildDice < 0 || wildDice > 50)
{
return ServiceResult<(int WildDice, bool AllowFumble)>.Failure("invalid_wild_dice", "Wild dice must be between 0 and 50.");
}
if (ruleset == RulesetKind.D6)
{
if (wildDice < 1)
{
return ServiceResult<(int WildDice, bool AllowFumble)>.Failure("invalid_wild_dice", "D6 skills require at least one wild die.");
}
return ServiceResult<(int WildDice, bool AllowFumble)>.Success((wildDice, allowFumble));
}
@@ -623,9 +475,7 @@ public sealed class GameService : IGameService
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, Skill skill)
{
return ruleset == RulesetKind.D6
? ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble)
: ComputeStandardRoll(expression);
return ruleset == RulesetKind.D6 ? ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble) : ComputeStandardRoll(expression);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeStandardRoll(DiceExpression expression)
@@ -637,7 +487,7 @@ public sealed class GameService : IGameService
{
var value = m_DiceRoller.Roll(expression.Sides);
diceValues[i] = value;
dice[i] = new RollDieResult(value, false, false, false, false, false);
dice[i] = new(value, false, false, false, false, false);
total += value;
}
@@ -686,27 +536,23 @@ public sealed class GameService : IGameService
}
}
dieResults.Add(new RollDieResult(roll, isCrit, isFumble, isWild, false, isAdded));
dieResults.Add(new(roll, isCrit, isFumble, isWild, false, isAdded));
}
for (var roll = expression.Sides; roll >= 1 && pendingFumbles > 0; roll -= 1)
for (var i = currentDice - 1; i >= 0 && pendingFumbles > 0; i -= 1)
{
for (var i = currentDice - 1; i >= 0 && pendingFumbles > 0; i -= 1)
{
if (dieResults[i].Roll != roll)
{
continue;
}
if (dieResults[i].Roll != roll)
continue;
dieResults[i] = dieResults[i] with
{
Removed = true,
Added = false,
Crit = false,
Fumble = false
};
pendingFumbles -= 1;
}
dieResults[i] = dieResults[i] with
{
Removed = true,
Added = false,
Crit = false,
Fumble = false
};
pendingFumbles -= 1;
}
var total = expression.Modifier;
@@ -732,9 +578,7 @@ public sealed class GameService : IGameService
{
var dicePart = string.Join("+", diceValues);
if (string.IsNullOrWhiteSpace(dicePart))
{
dicePart = "0";
}
var modifierPart = modifier > 0 ? $"+{modifier}" : string.Empty;
return $"{dicePart}{modifierPart}={total}";
@@ -743,14 +587,10 @@ public sealed class GameService : IGameService
private ServiceResult<RollVisibility> ParseVisibility(string visibility)
{
if (string.Equals(visibility, "public", StringComparison.OrdinalIgnoreCase))
{
return ServiceResult<RollVisibility>.Success(RollVisibility.Public);
}
if (string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase))
{
return ServiceResult<RollVisibility>.Success(RollVisibility.Private);
}
return ServiceResult<RollVisibility>.Failure("invalid_visibility", "Visibility must be 'public' or 'private'.");
}
@@ -759,73 +599,47 @@ public sealed class GameService : IGameService
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
{
return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("unauthorized", "You must be logged in.");
}
if (!m_CampaignsById.TryGetValue(campaignId, out var campaign))
{
return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("campaign_not_found", "Campaign was not found.");
}
if (!CanViewCampaignLocked(user.Id, campaign.Id))
{
return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("forbidden", "You are not a participant in this campaign.");
}
return ServiceResult<(UserAccount User, Campaign Campaign)>.Success((user, campaign));
}
private static UserSummary ToUserSummary(UserAccount user)
{
return new UserSummary(user.Id, user.Username, user.DisplayName);
return new(user.Id, user.Username, user.DisplayName);
}
private static CampaignSummary ToCampaignSummary(Campaign campaign)
{
return new CampaignSummary(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), campaign.GmUserId);
return new(campaign.Id, campaign.Name, DiceRules.ToRulesetId(campaign.Ruleset), campaign.GmUserId);
}
private static CharacterSummary ToCharacterSummary(Character character)
{
return new CharacterSummary(character.Id, character.Name, character.OwnerUserId, character.CampaignId);
return new(character.Id, character.Name, character.OwnerUserId, character.CampaignId);
}
private static SkillSummary ToSkillSummary(Skill skill)
{
return new SkillSummary(skill.Id, skill.CharacterId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble);
return new(skill.Id, skill.CharacterId, skill.Name, skill.DiceRollDefinition, skill.WildDice, skill.AllowFumble);
}
private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList<RollDieResult> dice)
{
return new RollResult(
entry.Id,
entry.CampaignId,
entry.CharacterId,
entry.SkillId,
entry.RollerUserId,
entry.Visibility == RollVisibility.Public ? "public" : "private",
entry.Result,
entry.Breakdown,
dice,
entry.TimestampUtc);
return new(entry.Id, entry.CampaignId, entry.CharacterId, entry.SkillId, entry.RollerUserId, entry.Visibility == RollVisibility.Public ? "public" : "private", entry.Result, entry.Breakdown, dice, entry.TimestampUtc);
}
private static CampaignLogEntry ToLogEntry(RollLogEntry entry)
{
var dice = DeserializeDice(entry.Dice);
return new CampaignLogEntry(
entry.Id,
entry.CampaignId,
entry.CharacterId,
entry.SkillId,
entry.RollerUserId,
entry.Visibility == RollVisibility.Public ? "public" : "private",
entry.Result,
entry.Breakdown,
dice,
entry.TimestampUtc);
return new(entry.Id, entry.CampaignId, entry.CharacterId, entry.SkillId, entry.RollerUserId, entry.Visibility == RollVisibility.Public ? "public" : "private", entry.Result, entry.Breakdown, dice, entry.TimestampUtc);
}
private static string SerializeDice(IReadOnlyList<RollDieResult> dice)
@@ -836,9 +650,7 @@ public sealed class GameService : IGameService
private static IReadOnlyList<RollDieResult> DeserializeDice(string serializedDice)
{
if (string.IsNullOrWhiteSpace(serializedDice))
{
return [];
}
try
{
@@ -859,9 +671,7 @@ public sealed class GameService : IGameService
{
var campaign = m_CampaignsById[campaignId];
if (campaign.GmUserId == userId)
{
return true;
}
return m_CharactersById.Values.Any(c => c.CampaignId == campaignId && c.OwnerUserId == userId);
}
@@ -870,9 +680,7 @@ public sealed class GameService : IGameService
{
campaignId = Guid.Empty;
if (user.ActiveCharacterId is not Guid activeCharacterId)
{
return false;
}
if (!m_CharactersById.TryGetValue(activeCharacterId, out var character))
{
@@ -902,14 +710,10 @@ public sealed class GameService : IGameService
private UserAccount? ResolveUserLocked(string sessionToken)
{
if (string.IsNullOrWhiteSpace(sessionToken))
{
return null;
}
if (!m_SessionsByToken.TryGetValue(sessionToken, out var session))
{
return null;
}
return m_UsersById.GetValueOrDefault(session.UserId);
}
@@ -917,9 +721,7 @@ public sealed class GameService : IGameService
private void TouchCampaignLocked(Guid campaignId)
{
if (m_CampaignsById.TryGetValue(campaignId, out var campaign))
{
campaign.Version += 1;
}
}
private void LoadStateFromDatabase()
@@ -930,10 +732,7 @@ public sealed class GameService : IGameService
var campaigns = db.Campaigns.AsNoTracking().ToList();
var characters = db.Characters.AsNoTracking().ToList();
var skills = db.Skills.AsNoTracking().ToList();
var logEntries = db.RollLogEntries.AsNoTracking().ToList()
.OrderBy(x => x.TimestampUtc)
.ThenBy(x => x.Id)
.ToList();
var logEntries = db.RollLogEntries.AsNoTracking().ToList().OrderBy(x => x.TimestampUtc).ThenBy(x => x.Id).ToList();
lock (m_Gate)
{
@@ -947,9 +746,7 @@ public sealed class GameService : IGameService
foreach (var user in users)
{
var normalizedUsername = string.IsNullOrWhiteSpace(user.UsernameNormalized)
? NormalizeUsername(user.Username)
: user.UsernameNormalized;
var normalizedUsername = string.IsNullOrWhiteSpace(user.UsernameNormalized) ? NormalizeUsername(user.Username) : user.UsernameNormalized;
var storedUser = new UserAccount
{
@@ -968,25 +765,17 @@ public sealed class GameService : IGameService
foreach (var session in sessions)
{
if (m_UsersById.ContainsKey(session.UserId))
{
m_SessionsByToken[session.Token] = CloneSession(session);
}
}
foreach (var campaign in campaigns)
{
m_CampaignsById[campaign.Id] = CloneCampaign(campaign);
}
foreach (var character in characters)
{
m_CharactersById[character.Id] = CloneCharacter(character);
}
foreach (var skill in skills)
{
m_SkillsById[skill.Id] = CloneSkill(skill);
}
m_RollLog.AddRange(logEntries.Select(CloneRollLogEntry));
}
@@ -1022,7 +811,7 @@ public sealed class GameService : IGameService
private static UserAccount CloneUser(UserAccount user)
{
return new UserAccount
return new()
{
Id = user.Id,
Username = user.Username,
@@ -1035,7 +824,7 @@ public sealed class GameService : IGameService
private static UserSession CloneSession(UserSession session)
{
return new UserSession
return new()
{
Token = session.Token,
UserId = session.UserId,
@@ -1045,7 +834,7 @@ public sealed class GameService : IGameService
private static Campaign CloneCampaign(Campaign campaign)
{
return new Campaign
return new()
{
Id = campaign.Id,
GmUserId = campaign.GmUserId,
@@ -1057,7 +846,7 @@ public sealed class GameService : IGameService
private static Character CloneCharacter(Character character)
{
return new Character
return new()
{
Id = character.Id,
OwnerUserId = character.OwnerUserId,
@@ -1068,7 +857,7 @@ public sealed class GameService : IGameService
private static Skill CloneSkill(Skill skill)
{
return new Skill
return new()
{
Id = skill.Id,
CharacterId = skill.CharacterId,
@@ -1081,7 +870,7 @@ public sealed class GameService : IGameService
private static RollLogEntry CloneRollLogEntry(RollLogEntry entry)
{
return new RollLogEntry
return new()
{
Id = entry.Id,
CampaignId = entry.CampaignId,
@@ -1095,4 +884,17 @@ public sealed class GameService : IGameService
TimestampUtc = entry.TimestampUtc
};
}
}
private static readonly JsonSerializerOptions DiceJsonOptions = new(JsonSerializerDefaults.Web);
private readonly Dictionary<Guid, Campaign> m_CampaignsById = [];
private readonly Dictionary<Guid, Character> m_CharactersById = [];
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
private readonly IDiceRoller m_DiceRoller;
private readonly object m_Gate = new();
private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
private readonly List<RollLogEntry> m_RollLog = [];
private readonly Dictionary<string, UserSession> m_SessionsByToken = new(StringComparer.Ordinal);
private readonly Dictionary<Guid, Skill> m_SkillsById = [];
private readonly Dictionary<string, Guid> m_UserIdsByUsername = new(StringComparer.Ordinal);
private readonly Dictionary<Guid, UserAccount> m_UsersById = [];
}

View File

@@ -3,4 +3,4 @@ namespace RpgRoller.Services;
public interface IDiceRoller
{
int Roll(int sides);
}
}

View File

@@ -1,5 +1,4 @@
using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Services;
@@ -29,4 +28,4 @@ public interface IGameService
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
ServiceResult<long> GetCampaignVersion(string sessionToken, Guid campaignId);
}
}

View File

@@ -1,9 +1,11 @@
using System.Security.Cryptography;
namespace RpgRoller.Services;
public sealed class RandomDiceRoller : IDiceRoller
{
public int Roll(int sides)
{
return System.Security.Cryptography.RandomNumberGenerator.GetInt32(1, sides + 1);
return RandomNumberGenerator.GetInt32(1, sides + 1);
}
}
}

View File

@@ -1 +1,2 @@
// Intentionally left blank after removing service command records.

View File

@@ -14,17 +14,17 @@ public sealed class ServiceResult<T>
Error = error;
}
public T? Value { get; }
public ServiceError? Error { get; }
public bool Succeeded => Error is null;
public static ServiceResult<T> Success(T value)
{
return new ServiceResult<T>(value);
return new(value);
}
public static ServiceResult<T> Failure(string code, string message)
{
return new ServiceResult<T>(new ServiceError(code, message));
return new(new ServiceError(code, message));
}
}
public T? Value { get; }
public ServiceError? Error { get; }
public bool Succeeded => Error is null;
}

View File

@@ -63,8 +63,7 @@ window.rpgRollerApi = (() => {
const payload = JSON.parse(event.data);
const version = typeof payload.version === "number" ? payload.version : 0;
invokeDotNet("OnStateEventReceived", version);
}
catch {
} catch {
invokeDotNet("OnStateEventReceived", 0);
}
});
@@ -124,8 +123,7 @@ window.rpgRollerApi = (() => {
let response;
try {
response = await fetch(url, options);
}
catch (error) {
} catch (error) {
return {
ok: false,
status: 0,
@@ -138,8 +136,7 @@ window.rpgRollerApi = (() => {
if (text) {
try {
parsed = JSON.parse(text);
}
catch {
} catch {
parsed = null;
}
}

View File

@@ -27,9 +27,8 @@ body {
}
body {
background:
radial-gradient(circle at 15% 10%, rgba(255, 255, 255, 0.32), transparent 45%),
linear-gradient(165deg, var(--bg-top), var(--bg-bottom));
background: radial-gradient(circle at 15% 10%, rgba(255, 255, 255, 0.32), transparent 45%),
linear-gradient(165deg, var(--bg-top), var(--bg-bottom));
color: var(--text);
font-family: "Trebuchet MS", "Lucida Sans Unicode", "Segoe UI", sans-serif;
line-height: 1.4;