Code cleanup
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,4 +33,4 @@ internal static class CampaignEndpoints
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,4 +33,4 @@ internal static class CharacterEndpoints
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,4 @@ internal static class MeEndpoints
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@ namespace RpgRoller.Api;
|
||||
|
||||
internal static class RequestMappings
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,4 @@ namespace RpgRoller.Api;
|
||||
internal static class SessionCookie
|
||||
{
|
||||
public const string Name = "rpgroller_session";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -27,4 +27,4 @@ internal static class SkillEndpoints
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,4 @@ internal static class SystemEndpoints
|
||||
group.MapGet("/rulesets", (IGameService game) => TypedResults.Ok(game.GetRulesets()));
|
||||
return group;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
@using System.Diagnostics.CodeAnalysis
|
||||
@using RpgRoller.Contracts
|
||||
|
||||
@if (Dice.Count > 0)
|
||||
{
|
||||
<div class="roll-dice-strip" aria-label="@AriaLabel">
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -14,4 +14,4 @@ public static class ApplicationInitializationExtensions
|
||||
SqliteSchemaUpgrader.ApplyPendingChanges(db);
|
||||
_ = scope.ServiceProvider.GetRequiredService<IGameService>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
];
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
@@ -3,4 +3,4 @@ namespace RpgRoller.Services;
|
||||
public interface IDiceRoller
|
||||
{
|
||||
int Roll(int sides);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
// Intentionally left blank after removing service command records.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user