Code Clenup

This commit is contained in:
2026-04-05 02:05:24 +02:00
parent a290ff87dd
commit b135203318
37 changed files with 661 additions and 992 deletions

View File

@@ -1,11 +1,7 @@
namespace RpgRoller.Tests;
public sealed class AuthApiTests : ApiTestBase
public sealed class AuthApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
{
public AuthApiTests(WebApplicationFactory<Program> factory) : base(factory)
{
}
[Fact]
public async Task RegisterLoginAndMeFlow_WorksWithDuplicateUsernameGuard()
{

View File

@@ -2,12 +2,8 @@ using System.Text;
namespace RpgRoller.Tests;
public sealed class CampaignApiTests : ApiTestBase
public sealed class CampaignApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
{
public CampaignApiTests(WebApplicationFactory<Program> factory) : base(factory)
{
}
[Fact]
public async Task CampaignCharacterAndSkillFlow_EnforcesRulesetValidation()
{

View File

@@ -1,11 +1,7 @@
namespace RpgRoller.Tests;
public sealed class FrontendHostTests : ApiTestBase
public sealed class FrontendHostTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
{
public FrontendHostTests(WebApplicationFactory<Program> factory) : base(factory)
{
}
[Fact]
public async Task RootPath_ServesBlazorFrontendShell()
{

View File

@@ -1,11 +1,7 @@
namespace RpgRoller.Tests;
public sealed class ResponseCompressionApiTests : ApiTestBase
public sealed class ResponseCompressionApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
{
public ResponseCompressionApiTests(WebApplicationFactory<Program> factory) : base(factory)
{
}
[Fact]
public async Task AuthenticatedJsonResponses_EnableGzipCompression()
{

View File

@@ -1,11 +1,7 @@
namespace RpgRoller.Tests;
public sealed class RolemasterApiTests : ApiTestBase
public sealed class RolemasterApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
{
public RolemasterApiTests(WebApplicationFactory<Program> factory) : base(factory)
{
}
[Fact]
public async Task RolemasterRollEndpoints_ExecuteGenericRolemasterExpressions()
{

View File

@@ -1,11 +1,7 @@
namespace RpgRoller.Tests;
public sealed class RollVisibilityApiTests : ApiTestBase
public sealed class RollVisibilityApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
{
public RollVisibilityApiTests(WebApplicationFactory<Program> factory) : base(factory)
{
}
[Fact]
public async Task RollVisibilityAndAuthorization_AreEnforced()
{

View File

@@ -1,11 +1,7 @@
namespace RpgRoller.Tests;
public sealed class SystemApiTests : ApiTestBase
public sealed class SystemApiTests(WebApplicationFactory<Program> factory) : ApiTestBase(factory)
{
public SystemApiTests(WebApplicationFactory<Program> factory) : base(factory)
{
}
[Fact]
public async Task RulesetAndSseEndpoints_ReturnExpectedResponses()
{

View File

@@ -11,11 +11,11 @@ public sealed class BackendCoverageTests
var extensionsType = typeof(Program).Assembly.GetType("RpgRoller.Api.SessionTokenHttpContextExtensions");
Assert.NotNull(extensionsType);
var method = extensionsType!.GetMethod("GetRequiredSessionToken", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
var method = extensionsType.GetMethod("GetRequiredSessionToken", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
Assert.NotNull(method);
var context = new DefaultHttpContext();
var exception = Assert.Throws<TargetInvocationException>(() => method!.Invoke(null, [context]));
var exception = Assert.Throws<TargetInvocationException>(() => method.Invoke(null, [context]));
Assert.IsType<InvalidOperationException>(exception.InnerException);
}
}

View File

@@ -2,20 +2,15 @@ namespace RpgRoller.Tests;
public sealed class ServiceRollHelperTests
{
private sealed class FixedDiceRoller : IDiceRoller
private sealed class FixedDiceRoller(IEnumerable<int> values) : IDiceRoller
{
public FixedDiceRoller(IEnumerable<int> values)
{
m_Values = new(values);
}
public int Roll(int sides)
{
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
return Math.Clamp(next, 1, sides);
}
private readonly Queue<int> m_Values;
private readonly Queue<int> m_Values = new(values);
}
[Fact]

View File

@@ -88,7 +88,7 @@ public sealed class ServiceSkillGroupAndOwnershipTests
var gmTransfer = ServiceTestSupport.GetValue(service.UpdateCharacter(gmSession, character.Id, "Transferred", campaign.Id, "receiver"));
var receiver = service.GetUserBySession(receiverSession);
Assert.NotNull(receiver);
Assert.Equal(receiver!.Id, gmTransfer.OwnerUserId);
Assert.Equal(receiver.Id, gmTransfer.OwnerUserId);
var previousOwnerMe = ServiceTestSupport.GetValue(service.GetMe(ownerSession));
Assert.Null(previousOwnerMe.ActiveCharacterId);
@@ -133,7 +133,7 @@ public sealed class ServiceSkillGroupAndOwnershipTests
var adminTwo = service.GetUserBySession(adminTwoSession);
Assert.NotNull(adminTwo);
_ = ServiceTestSupport.GetValue(service.UpdateUserRoles(gmSession, adminTwo!.Id, ["admin"]));
_ = ServiceTestSupport.GetValue(service.UpdateUserRoles(gmSession, adminTwo.Id, ["admin"]));
var adminUnlink = ServiceTestSupport.GetValue(service.UpdateCharacter(adminTwoSession, character.Id, "Admin Unlink", null));
Assert.Null(adminUnlink.CampaignId);

View File

@@ -8,32 +8,22 @@ using RpgRoller.Hosting;
namespace RpgRoller.Tests;
public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>>
public abstract class ApiTestBase(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
private sealed class FixedDiceRoller : IDiceRoller
private sealed class FixedDiceRoller(IEnumerable<int> values) : IDiceRoller
{
public FixedDiceRoller(IEnumerable<int> values)
{
m_Values = new(values);
}
public int Roll(int sides)
{
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
return Math.Clamp(next, 1, sides);
}
private readonly Queue<int> m_Values;
}
protected ApiTestBase(WebApplicationFactory<Program> factory)
{
m_BaseFactory = factory;
private readonly Queue<int> m_Values = new(values);
}
protected WebApplicationFactory<Program> CreateFactory(params int[] rollValues)
{
return m_BaseFactory.WithWebHostBuilder(builder =>
return factory.WithWebHostBuilder(builder =>
{
builder.ConfigureLogging(logging =>
{
@@ -95,6 +85,4 @@ public abstract class ApiTestBase : IClassFixture<WebApplicationFactory<Program>
Assert.NotNull(result);
return result;
}
private readonly WebApplicationFactory<Program> m_BaseFactory;
}

View File

@@ -46,29 +46,19 @@ internal static class ServiceTestSupport
public int HashCalls { get; private set; }
}
private sealed class FixedDiceRoller : IDiceRoller
private sealed class FixedDiceRoller(IEnumerable<int> values) : IDiceRoller
{
public FixedDiceRoller(IEnumerable<int> values)
{
m_Values = new(values);
}
public int Roll(int sides)
{
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
return Math.Clamp(next, 1, sides);
}
private readonly Queue<int> m_Values;
private readonly Queue<int> m_Values = new(values);
}
internal sealed class SqliteDbContextFactory : IDbContextFactory<RpgRollerDbContext>, IDisposable
internal sealed class SqliteDbContextFactory(string dbPath) : IDbContextFactory<RpgRollerDbContext>, IDisposable
{
public SqliteDbContextFactory(string dbPath)
{
m_Options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite($"Data Source={dbPath}").Options;
}
public RpgRollerDbContext CreateDbContext()
{
return new(m_Options);
@@ -78,7 +68,7 @@ internal static class ServiceTestSupport
{
}
private readonly DbContextOptions<RpgRollerDbContext> m_Options;
private readonly DbContextOptions<RpgRollerDbContext> m_Options = new DbContextOptionsBuilder<RpgRollerDbContext>().UseSqlite($"Data Source={dbPath}").Options;
}
internal static ServiceHarness CreateHarness(params int[] rollValues)

View File

@@ -6,114 +6,93 @@ using RpgRoller.Domain;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class WorkspaceAdminCoordinator
public sealed class WorkspaceAdminCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Action clearAuthenticatedState, Func<Task> stopStateEventsAsync, Func<string?, Task> onLoggedOutAsync)
{
public WorkspaceAdminCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Action clearAuthenticatedState, Func<Task> stopStateEventsAsync, Func<string?, Task> onLoggedOutAsync)
{
m_State = state;
m_Feedback = feedback;
m_JS = js;
m_ApiClient = apiClient;
m_WorkspaceQuery = workspaceQuery;
m_ClearAuthenticatedState = clearAuthenticatedState;
m_StopStateEventsAsync = stopStateEventsAsync;
m_OnLoggedOutAsync = onLoggedOutAsync;
}
public async Task EnsureAdminUsersLoadedAsync()
{
if (!m_State.IsCurrentUserAdmin || m_State.HasLoadedAdminUsers || m_State.IsAdminDataLoading)
if (!state.IsCurrentUserAdmin || state.HasLoadedAdminUsers || state.IsAdminDataLoading)
return;
m_State.IsAdminDataLoading = true;
state.IsAdminDataLoading = true;
try
{
await ReloadAdminUsersAsync();
}
catch (ApiRequestException ex) when (ex.StatusCode == 401)
{
m_ClearAuthenticatedState();
await m_StopStateEventsAsync();
await m_OnLoggedOutAsync("Session expired. Please log in again.");
clearAuthenticatedState();
await stopStateEventsAsync();
await onLoggedOutAsync("Session expired. Please log in again.");
}
catch (ApiRequestException ex)
{
m_Feedback.SetStatus(ex.Message, true);
feedback.SetStatus(ex.Message, true);
}
finally
{
m_State.IsAdminDataLoading = false;
state.IsAdminDataLoading = false;
}
}
public async Task ToggleAdminRoleAsync(AdminUserSummary user)
{
if (m_State.IsMutating || m_State.User is null || !m_State.IsCurrentUserAdmin || user.Id == m_State.User.Id)
if (state.IsMutating || state.User is null || !state.IsCurrentUserAdmin || user.Id == state.User.Id)
return;
m_State.IsMutating = true;
state.IsMutating = true;
try
{
IReadOnlyList<string> roles = HasAdminRole(user) ? Array.Empty<string>() : [UserRoles.Admin];
_ = await m_ApiClient.RequestAsync<AdminUserSummary>("PUT", $"/api/admin/users/{user.Id}/roles", new UpdateUserRolesRequest(roles));
_ = await apiClient.RequestAsync<AdminUserSummary>("PUT", $"/api/admin/users/{user.Id}/roles", new UpdateUserRolesRequest(roles));
await ReloadAdminUsersAsync();
m_Feedback.SetStatus("User roles updated.", false);
feedback.SetStatus("User roles updated.", false);
}
catch (ApiRequestException ex)
{
m_Feedback.SetStatus(ex.Message, true);
feedback.SetStatus(ex.Message, true);
}
finally
{
m_State.IsMutating = false;
state.IsMutating = false;
}
}
public async Task DeleteUserAsync(AdminUserSummary user)
{
if (m_State.IsMutating || m_State.User is null || !m_State.IsCurrentUserAdmin || user.Id == m_State.User.Id)
if (state.IsMutating || state.User is null || !state.IsCurrentUserAdmin || user.Id == state.User.Id)
return;
var confirmed = await m_JS.InvokeAsync<bool>("confirm", $"Delete user '{user.Username}'?");
var confirmed = await js.InvokeAsync<bool>("confirm", $"Delete user '{user.Username}'?");
if (!confirmed)
return;
m_State.IsMutating = true;
state.IsMutating = true;
try
{
_ = await m_ApiClient.RequestAsync<bool>("DELETE", $"/api/admin/users/{user.Id}");
_ = await apiClient.RequestAsync<bool>("DELETE", $"/api/admin/users/{user.Id}");
await ReloadAdminUsersAsync();
m_Feedback.SetStatus("User deleted.", false);
feedback.SetStatus("User deleted.", false);
}
catch (ApiRequestException ex)
{
m_Feedback.SetStatus(ex.Message, true);
feedback.SetStatus(ex.Message, true);
}
finally
{
m_State.IsMutating = false;
state.IsMutating = false;
}
}
private async Task ReloadAdminUsersAsync()
{
m_State.AdminUsers = (await m_WorkspaceQuery.GetAdminUsersAsync()).OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase).ToList();
state.AdminUsers = (await workspaceQuery.GetAdminUsersAsync()).OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase).ToList();
m_State.HasLoadedAdminUsers = true;
state.HasLoadedAdminUsers = true;
}
private static bool HasAdminRole(AdminUserSummary user)
{
return user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase);
}
private readonly RpgRollerApiClient m_ApiClient;
private readonly Action m_ClearAuthenticatedState;
private readonly WorkspaceFeedbackService m_Feedback;
private readonly IJSRuntime m_JS;
private readonly Func<string?, Task> m_OnLoggedOutAsync;
private readonly WorkspaceState m_State;
private readonly Func<Task> m_StopStateEventsAsync;
private readonly WorkspaceQueryService m_WorkspaceQuery;
}

View File

@@ -6,179 +6,156 @@ using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class WorkspaceCampaignCoordinator
public sealed class WorkspaceCampaignCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, Func<Task> loadKnownUsernamesAsync, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> syncStateEventsAsync)
{
public WorkspaceCampaignCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, Func<Task> loadKnownUsernamesAsync, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> syncStateEventsAsync)
{
m_State = state;
m_Feedback = feedback;
m_JS = js;
m_ApiClient = apiClient;
m_LoadKnownUsernamesAsync = loadKnownUsernamesAsync;
m_ReloadCampaignsAsync = reloadCampaignsAsync;
m_ReloadCharacterCampaignOptionsAsync = reloadCharacterCampaignOptionsAsync;
m_RefreshCampaignScopeAsync = refreshCampaignScopeAsync;
m_SyncStateEventsAsync = syncStateEventsAsync;
}
public async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
{
if (!Guid.TryParse(args.Value?.ToString(), out var campaignId))
return;
m_State.SelectedCampaignId = campaignId;
await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString());
await m_RefreshCampaignScopeAsync();
await m_SyncStateEventsAsync();
m_State.IsScreenMenuOpen = false;
state.SelectedCampaignId = campaignId;
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString());
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
state.IsScreenMenuOpen = false;
}
public async Task OnCampaignCreatedAsync(Guid campaignId)
{
await m_ReloadCampaignsAsync(campaignId);
await m_ReloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync();
await m_SyncStateEventsAsync();
m_Feedback.SetStatus("Campaign created.", false);
await reloadCampaignsAsync(campaignId);
await reloadCharacterCampaignOptionsAsync();
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
feedback.SetStatus("Campaign created.", false);
}
public void OpenCreateCharacterModal()
{
m_State.CreateCharacterInitialModel = new()
state.CreateCharacterInitialModel = new()
{
Name = string.Empty,
CampaignId = m_State.SelectedCampaignId?.ToString() ?? m_State.CharacterCampaignOptions.FirstOrDefault()?.Id.ToString() ?? string.Empty,
CampaignId = state.SelectedCampaignId?.ToString() ?? state.CharacterCampaignOptions.FirstOrDefault()?.Id.ToString() ?? string.Empty,
OwnerUsername = string.Empty
};
m_State.CreateCharacterFormVersion += 1;
m_State.CanEditCharacterOwner = false;
m_State.ShowCreateCharacterModal = true;
state.CreateCharacterFormVersion += 1;
state.CanEditCharacterOwner = false;
state.ShowCreateCharacterModal = true;
}
public async Task OpenEditCharacterModal(CharacterSummary character)
{
if (m_State.IsCurrentUserGm || m_State.IsCurrentUserAdmin)
await m_LoadKnownUsernamesAsync();
if (state.IsCurrentUserGm || state.IsCurrentUserAdmin)
await loadKnownUsernamesAsync();
m_State.EditingCharacterId = character.Id;
m_State.EditCharacterInitialModel = new()
state.EditingCharacterId = character.Id;
state.EditCharacterInitialModel = new()
{
Name = character.Name,
CampaignId = character.CampaignId?.ToString() ?? string.Empty,
OwnerUsername = string.Empty
};
m_State.EditCharacterFormVersion += 1;
m_State.CanEditCharacterOwner = m_State.IsCurrentUserGm || m_State.IsCurrentUserAdmin;
m_State.ShowEditCharacterModal = true;
state.EditCharacterFormVersion += 1;
state.CanEditCharacterOwner = state.IsCurrentUserGm || state.IsCurrentUserAdmin;
state.ShowEditCharacterModal = true;
}
public void CloseCharacterModals()
{
m_State.ShowCreateCharacterModal = false;
m_State.ShowEditCharacterModal = false;
m_State.CanEditCharacterOwner = false;
m_State.EditingCharacterId = null;
state.ShowCreateCharacterModal = false;
state.ShowEditCharacterModal = false;
state.CanEditCharacterOwner = false;
state.EditingCharacterId = null;
}
public async Task OnCharacterCreatedAsync(Guid? campaignId)
{
CloseCharacterModals();
await m_ReloadCampaignsAsync(campaignId);
await m_ReloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync();
await m_SyncStateEventsAsync();
m_Feedback.SetStatus("Character created.", false);
await reloadCampaignsAsync(campaignId);
await reloadCharacterCampaignOptionsAsync();
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
feedback.SetStatus("Character created.", false);
}
public async Task OnCharacterUpdatedAsync(Guid? campaignId)
{
CloseCharacterModals();
await m_ReloadCampaignsAsync(campaignId);
await m_ReloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync();
await m_SyncStateEventsAsync();
m_Feedback.SetStatus(campaignId.HasValue ? "Character updated." : "Character unlinked from campaign.", false);
await reloadCampaignsAsync(campaignId);
await reloadCharacterCampaignOptionsAsync();
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
feedback.SetStatus(campaignId.HasValue ? "Character updated." : "Character unlinked from campaign.", false);
}
public async Task DeleteSelectedCampaignAsync()
{
if (m_State.SelectedCampaign is null || m_State.IsMutating || !m_State.CanDeleteSelectedCampaign)
if (state.SelectedCampaign is null || state.IsMutating || !state.CanDeleteSelectedCampaign)
return;
var confirmed = await m_JS.InvokeAsync<bool>("confirm", $"Delete campaign '{m_State.SelectedCampaign.Name}'?");
var confirmed = await js.InvokeAsync<bool>("confirm", $"Delete campaign '{state.SelectedCampaign.Name}'?");
if (!confirmed)
return;
m_State.IsMutating = true;
state.IsMutating = true;
try
{
_ = await m_ApiClient.RequestAsync<bool>("DELETE", $"/api/campaigns/{m_State.SelectedCampaign.Id}");
await m_ReloadCampaignsAsync(null);
await m_ReloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync();
await m_SyncStateEventsAsync();
m_Feedback.SetStatus("Campaign deleted.", false);
_ = await apiClient.RequestAsync<bool>("DELETE", $"/api/campaigns/{state.SelectedCampaign.Id}");
await reloadCampaignsAsync(null);
await reloadCharacterCampaignOptionsAsync();
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
feedback.SetStatus("Campaign deleted.", false);
}
catch (ApiRequestException ex)
{
m_Feedback.SetStatus(ex.Message, true);
feedback.SetStatus(ex.Message, true);
}
finally
{
m_State.IsMutating = false;
state.IsMutating = false;
}
}
public async Task DeleteCharacterAsync(CharacterSummary character)
{
if (m_State.IsMutating || !CanDeleteCharacter(character))
if (state.IsMutating || !CanDeleteCharacter(character))
return;
var confirmed = await m_JS.InvokeAsync<bool>("confirm", $"Delete character '{character.Name}'?");
var confirmed = await js.InvokeAsync<bool>("confirm", $"Delete character '{character.Name}'?");
if (!confirmed)
return;
m_State.IsMutating = true;
state.IsMutating = true;
try
{
_ = await m_ApiClient.RequestAsync<bool>("DELETE", $"/api/characters/{character.Id}");
await m_ReloadCampaignsAsync(m_State.SelectedCampaignId);
await m_ReloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync();
await m_SyncStateEventsAsync();
m_Feedback.SetStatus("Character deleted.", false);
_ = await apiClient.RequestAsync<bool>("DELETE", $"/api/characters/{character.Id}");
await reloadCampaignsAsync(state.SelectedCampaignId);
await reloadCharacterCampaignOptionsAsync();
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
feedback.SetStatus("Character deleted.", false);
}
catch (ApiRequestException ex)
{
m_Feedback.SetStatus(ex.Message, true);
feedback.SetStatus(ex.Message, true);
}
finally
{
m_State.IsMutating = false;
state.IsMutating = false;
}
}
public bool CanEditCharacter(CharacterSummary character)
{
return m_State.User is not null && (character.OwnerUserId == m_State.User.Id || m_State.IsCurrentUserGm || m_State.IsCurrentUserAdmin);
return state.User is not null && (character.OwnerUserId == state.User.Id || state.IsCurrentUserGm || state.IsCurrentUserAdmin);
}
public bool CanDeleteCharacter(CharacterSummary character)
{
return m_State.User is not null && (character.OwnerUserId == m_State.User.Id || m_State.IsCurrentUserAdmin);
return state.User is not null && (character.OwnerUserId == state.User.Id || state.IsCurrentUserAdmin);
}
private const string CampaignSessionKey = "campaign";
private readonly RpgRollerApiClient m_ApiClient;
private readonly WorkspaceFeedbackService m_Feedback;
private readonly IJSRuntime m_JS;
private readonly Func<Task> m_LoadKnownUsernamesAsync;
private readonly Func<Task> m_RefreshCampaignScopeAsync;
private readonly Func<Guid?, Task> m_ReloadCampaignsAsync;
private readonly Func<Task> m_ReloadCharacterCampaignOptionsAsync;
private readonly WorkspaceState m_State;
private readonly Func<Task> m_SyncStateEventsAsync;
}

View File

@@ -4,149 +4,120 @@ using Microsoft.JSInterop;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class WorkspaceCampaignScopeCoordinator
public sealed class WorkspaceCampaignScopeCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, WorkspaceQueryService workspaceQuery, Func<Task> ensureSelectedCharacterActiveAsync, Func<Task> refreshSelectedCharacterSheetAsync, Func<Guid?, Task> refreshCampaignLogAsync, Action resetCampaignLogDetailState, Action resetCampaignStateTracking, Action clearAuthenticatedState, Func<Task> stopStateEventsAsync, Func<string?, Task> onLoggedOutAsync)
{
public WorkspaceCampaignScopeCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, WorkspaceQueryService workspaceQuery, Func<Task> ensureSelectedCharacterActiveAsync, Func<Task> refreshSelectedCharacterSheetAsync, Func<Guid?, Task> refreshCampaignLogAsync, Action resetCampaignLogDetailState, Action resetCampaignStateTracking, Action clearAuthenticatedState, Func<Task> stopStateEventsAsync, Func<string?, Task> onLoggedOutAsync)
{
m_State = state;
m_Feedback = feedback;
m_JS = js;
m_WorkspaceQuery = workspaceQuery;
m_EnsureSelectedCharacterActiveAsync = ensureSelectedCharacterActiveAsync;
m_RefreshSelectedCharacterSheetAsync = refreshSelectedCharacterSheetAsync;
m_RefreshCampaignLogAsync = refreshCampaignLogAsync;
m_ResetCampaignLogDetailState = resetCampaignLogDetailState;
m_ResetCampaignStateTracking = resetCampaignStateTracking;
m_ClearAuthenticatedState = clearAuthenticatedState;
m_StopStateEventsAsync = stopStateEventsAsync;
m_OnLoggedOutAsync = onLoggedOutAsync;
}
public async Task ReloadCampaignsAsync(Guid? preferredCampaignId)
{
var campaigns = await m_WorkspaceQuery.GetCampaignsAsync();
m_State.Campaigns = campaigns.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList();
var campaigns = await workspaceQuery.GetCampaignsAsync();
state.Campaigns = campaigns.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList();
if (m_State.Campaigns.Count == 0)
if (state.Campaigns.Count == 0)
{
m_State.SelectedCampaignId = null;
await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, null);
state.SelectedCampaignId = null;
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, null);
return;
}
var campaignIds = m_State.Campaigns.Select(campaign => campaign.Id).ToHashSet();
var campaignIds = state.Campaigns.Select(campaign => campaign.Id).ToHashSet();
if (preferredCampaignId.HasValue && campaignIds.Contains(preferredCampaignId.Value))
m_State.SelectedCampaignId = preferredCampaignId.Value;
else if (!m_State.SelectedCampaignId.HasValue || !campaignIds.Contains(m_State.SelectedCampaignId.Value))
m_State.SelectedCampaignId = m_State.Campaigns[0].Id;
state.SelectedCampaignId = preferredCampaignId.Value;
else if (!state.SelectedCampaignId.HasValue || !campaignIds.Contains(state.SelectedCampaignId.Value))
state.SelectedCampaignId = state.Campaigns[0].Id;
await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, m_State.SelectedCampaignId?.ToString());
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, state.SelectedCampaignId?.ToString());
}
public async Task ReloadCharacterCampaignOptionsAsync()
{
var campaignOptions = await m_WorkspaceQuery.GetCharacterCampaignOptionsAsync();
m_State.CharacterCampaignOptions = campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList();
var campaignOptions = await workspaceQuery.GetCharacterCampaignOptionsAsync();
state.CharacterCampaignOptions = campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
public async Task RefreshCampaignRosterAsync()
{
if (!m_State.SelectedCampaignId.HasValue)
if (!state.SelectedCampaignId.HasValue)
{
m_State.SelectedCampaign = null;
m_State.SelectedCharacterId = null;
state.SelectedCampaign = null;
state.SelectedCharacterId = null;
return;
}
m_State.SelectedCampaign = await m_WorkspaceQuery.GetCampaignAsync(m_State.SelectedCampaignId.Value);
state.SelectedCampaign = await workspaceQuery.GetCampaignAsync(state.SelectedCampaignId.Value);
SyncSelectedCharacter();
if (m_State.IsPlayScreen && m_State.PlaySelectedCharacterId.HasValue && m_State.SelectedCharacterId != m_State.PlaySelectedCharacterId)
m_State.SelectedCharacterId = m_State.PlaySelectedCharacterId;
if (state.IsPlayScreen && state.PlaySelectedCharacterId.HasValue && state.SelectedCharacterId != state.PlaySelectedCharacterId)
state.SelectedCharacterId = state.PlaySelectedCharacterId;
await m_EnsureSelectedCharacterActiveAsync();
await ensureSelectedCharacterActiveAsync();
}
public async Task RefreshCampaignScopeAsync()
{
if (!m_State.SelectedCampaignId.HasValue)
if (!state.SelectedCampaignId.HasValue)
{
m_State.SelectedCampaign = null;
m_State.SelectedCharacterSkills = [];
m_State.SelectedCharacterSkillGroups = [];
m_State.CampaignLog = [];
m_State.SelectedCharacterId = null;
m_State.ConnectionState = "offline";
m_State.CurrentCampaignState = null;
m_State.CampaignLogCursor = null;
m_ResetCampaignLogDetailState();
state.SelectedCampaign = null;
state.SelectedCharacterSkills = [];
state.SelectedCharacterSkillGroups = [];
state.CampaignLog = [];
state.SelectedCharacterId = null;
state.ConnectionState = "offline";
state.CurrentCampaignState = null;
state.CampaignLogCursor = null;
resetCampaignLogDetailState();
return;
}
m_State.IsCampaignDataLoading = true;
state.IsCampaignDataLoading = true;
try
{
await RefreshCampaignRosterAsync();
await m_RefreshSelectedCharacterSheetAsync();
await m_RefreshCampaignLogAsync(null);
m_ResetCampaignStateTracking();
await refreshSelectedCharacterSheetAsync();
await refreshCampaignLogAsync(null);
resetCampaignStateTracking();
}
catch (ApiRequestException ex) when (ex.StatusCode == 401)
{
m_ClearAuthenticatedState();
await m_StopStateEventsAsync();
await m_OnLoggedOutAsync("Session expired. Please log in again.");
clearAuthenticatedState();
await stopStateEventsAsync();
await onLoggedOutAsync("Session expired. Please log in again.");
}
catch (ApiRequestException ex)
{
m_Feedback.SetStatus(ex.Message, true);
feedback.SetStatus(ex.Message, true);
}
finally
{
m_State.IsCampaignDataLoading = false;
state.IsCampaignDataLoading = false;
}
}
public async Task SetMobilePanelAsync(string panel)
{
m_State.MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character";
await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, m_State.MobilePanel);
state.MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character";
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, state.MobilePanel);
}
private void SyncSelectedCharacter()
{
if (m_State.SelectedCampaign is null || m_State.SelectedCampaign.Characters.Length == 0)
if (state.SelectedCampaign is null || state.SelectedCampaign.Characters.Length == 0)
{
m_State.SelectedCharacterId = null;
state.SelectedCharacterId = null;
return;
}
var candidateIds = m_State.SelectedCampaign.Characters.Select(character => character.Id).ToHashSet();
if (m_State.SelectedCharacterId.HasValue && candidateIds.Contains(m_State.SelectedCharacterId.Value))
var candidateIds = state.SelectedCampaign.Characters.Select(character => character.Id).ToHashSet();
if (state.SelectedCharacterId.HasValue && candidateIds.Contains(state.SelectedCharacterId.Value))
return;
if (m_State.ActiveCharacterId.HasValue && candidateIds.Contains(m_State.ActiveCharacterId.Value))
if (state.ActiveCharacterId.HasValue && candidateIds.Contains(state.ActiveCharacterId.Value))
{
m_State.SelectedCharacterId = m_State.ActiveCharacterId;
state.SelectedCharacterId = state.ActiveCharacterId;
return;
}
m_State.SelectedCharacterId = m_State.SelectedCampaign.Characters[0].Id;
state.SelectedCharacterId = state.SelectedCampaign.Characters[0].Id;
}
private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel";
private readonly Action m_ClearAuthenticatedState;
private readonly Func<Task> m_EnsureSelectedCharacterActiveAsync;
private readonly WorkspaceFeedbackService m_Feedback;
private readonly IJSRuntime m_JS;
private readonly Func<string?, Task> m_OnLoggedOutAsync;
private readonly Func<Guid?, Task> m_RefreshCampaignLogAsync;
private readonly Func<Task> m_RefreshSelectedCharacterSheetAsync;
private readonly Action m_ResetCampaignLogDetailState;
private readonly Action m_ResetCampaignStateTracking;
private readonly WorkspaceState m_State;
private readonly Func<Task> m_StopStateEventsAsync;
private readonly WorkspaceQueryService m_WorkspaceQuery;
}

View File

@@ -1,13 +1,7 @@
namespace RpgRoller.Components.Pages;
public sealed class WorkspaceFeedbackService
public sealed class WorkspaceFeedbackService(WorkspaceState state, Func<Task> requestRefreshAsync)
{
public WorkspaceFeedbackService(WorkspaceState state, Func<Task> requestRefreshAsync)
{
m_State = state;
m_RequestRefreshAsync = requestRefreshAsync;
}
public void SetStatus(string message, bool isError)
{
Announce(message);
@@ -16,18 +10,18 @@ public sealed class WorkspaceFeedbackService
public void Announce(string message)
{
m_State.LiveAnnouncement = message;
state.LiveAnnouncement = message;
}
public void ClearToasts()
{
m_State.Toasts.Clear();
state.Toasts.Clear();
}
private void AddToast(string message, bool isError)
{
var toastId = Guid.NewGuid();
m_State.Toasts.Add(new(toastId, message, isError));
state.Toasts.Add(new(toastId, message, isError));
_ = DismissToastLaterAsync(toastId);
}
@@ -35,12 +29,12 @@ public sealed class WorkspaceFeedbackService
{
await Task.Delay(ToastDurationMs);
if (m_State.Toasts.RemoveAll(toast => toast.Id == toastId) == 0)
if (state.Toasts.RemoveAll(toast => toast.Id == toastId) == 0)
return;
try
{
await m_RequestRefreshAsync();
await requestRefreshAsync();
}
catch (ObjectDisposedException)
{
@@ -48,7 +42,4 @@ public sealed class WorkspaceFeedbackService
}
private const int ToastDurationMs = 3200;
private readonly Func<Task> m_RequestRefreshAsync;
private readonly WorkspaceState m_State;
}

View File

@@ -4,101 +4,89 @@ using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class WorkspaceLiveStateController
public sealed class WorkspaceLiveStateController(WorkspaceState state, WorkspaceFeedbackService feedback, Func<Guid, Task> startStateEventsAsync, Func<Task> stopStateEventsCoreAsync, Func<Task> refreshCampaignRosterAsync, Func<Task> refreshSelectedCharacterSheetAsync, Func<Guid?, Task> refreshCampaignLogAsync, Func<Task> requestRefreshAsync)
{
public WorkspaceLiveStateController(WorkspaceState state, WorkspaceFeedbackService feedback, Func<Guid, Task> startStateEventsAsync, Func<Task> stopStateEventsCoreAsync, Func<Task> refreshCampaignRosterAsync, Func<Task> refreshSelectedCharacterSheetAsync, Func<Guid?, Task> refreshCampaignLogAsync, Func<Task> requestRefreshAsync)
public async Task OnStateEventReceivedAsync(CampaignStateSnapshot state1)
{
m_State = state;
m_Feedback = feedback;
m_StartStateEventsAsync = startStateEventsAsync;
m_StopStateEventsCoreAsync = stopStateEventsCoreAsync;
m_RefreshCampaignRosterAsync = refreshCampaignRosterAsync;
m_RefreshSelectedCharacterSheetAsync = refreshSelectedCharacterSheetAsync;
m_RefreshCampaignLogAsync = refreshCampaignLogAsync;
m_RequestRefreshAsync = requestRefreshAsync;
}
public async Task OnStateEventReceivedAsync(CampaignStateSnapshot state)
{
if (m_State.StateRefreshInProgress)
if (state.StateRefreshInProgress)
return;
if (!m_State.SelectedCampaignId.HasValue || state.CampaignId != m_State.SelectedCampaignId.Value)
if (!state.SelectedCampaignId.HasValue || state1.CampaignId != state.SelectedCampaignId.Value)
return;
m_State.StateRefreshInProgress = true;
state.StateRefreshInProgress = true;
try
{
if (m_State.CurrentCampaignState is null)
if (state.CurrentCampaignState is null)
{
m_State.CurrentCampaignState = state;
state.CurrentCampaignState = state1;
return;
}
var previousState = m_State.CurrentCampaignState;
var previousSelectedCharacterId = m_State.SelectedCharacterId;
var previousState = state.CurrentCampaignState;
var previousSelectedCharacterId = state.SelectedCharacterId;
var previousSelectedCharacterVersion = GetCharacterVersion(previousState, previousSelectedCharacterId);
var rosterChanged = state.RosterVersion != previousState.RosterVersion;
var logChanged = m_State.IsPlayScreen && state.LogVersion != previousState.LogVersion;
var rosterChanged = state1.RosterVersion != previousState.RosterVersion;
var logChanged = state.IsPlayScreen && state1.LogVersion != previousState.LogVersion;
if (rosterChanged)
await m_RefreshCampaignRosterAsync();
await refreshCampaignRosterAsync();
var selectedCharacterChanged = previousSelectedCharacterId != m_State.SelectedCharacterId;
var selectedCharacterVersionChanged = m_State.IsPlayScreen && !selectedCharacterChanged && GetCharacterVersion(state, m_State.SelectedCharacterId) != previousSelectedCharacterVersion;
var selectedCharacterChanged = previousSelectedCharacterId != state.SelectedCharacterId;
var selectedCharacterVersionChanged = state.IsPlayScreen && !selectedCharacterChanged && GetCharacterVersion(state1, state.SelectedCharacterId) != previousSelectedCharacterVersion;
if (m_State.IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged))
await m_RefreshSelectedCharacterSheetAsync();
if (state.IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged))
await refreshSelectedCharacterSheetAsync();
if (logChanged)
await m_RefreshCampaignLogAsync(m_State.CampaignLogCursor);
await refreshCampaignLogAsync(state.CampaignLogCursor);
m_State.CurrentCampaignState = state;
state.CurrentCampaignState = state1;
}
finally
{
m_State.StateRefreshInProgress = false;
await m_RequestRefreshAsync();
state.StateRefreshInProgress = false;
await requestRefreshAsync();
}
}
public async Task OnConnectionStateChangedAsync(string state)
public async Task OnConnectionStateChangedAsync(string state1)
{
m_State.ConnectionState = state switch
state.ConnectionState = state1 switch
{
"connected" => "connected",
"reconnecting" => "reconnecting",
_ => "offline"
};
if (m_State.ConnectionState == "reconnecting")
m_Feedback.Announce("Reconnecting to live updates.");
if (state.ConnectionState == "reconnecting")
feedback.Announce("Reconnecting to live updates.");
if (m_State.ConnectionState == "offline")
m_Feedback.Announce("Live updates offline. Use manual refresh.");
if (state.ConnectionState == "offline")
feedback.Announce("Live updates offline. Use manual refresh.");
await m_RequestRefreshAsync();
await requestRefreshAsync();
}
public async Task SyncStateEventsAsync()
{
if (m_State.User is null || !m_State.SelectedCampaignId.HasValue || m_State.IsAdminScreen)
if (state.User is null || !state.SelectedCampaignId.HasValue || state.IsAdminScreen)
{
await StopStateEventsAsync();
m_State.ConnectionState = "offline";
state.ConnectionState = "offline";
return;
}
await m_StartStateEventsAsync(m_State.SelectedCampaignId.Value);
m_State.ConnectionState = "reconnecting";
await startStateEventsAsync(state.SelectedCampaignId.Value);
state.ConnectionState = "reconnecting";
}
public async Task StopStateEventsAsync()
{
if (!m_State.HasInteractiveRenderStarted)
if (!state.HasInteractiveRenderStarted)
return;
await m_StopStateEventsCoreAsync();
await stopStateEventsCoreAsync();
}
private static long GetCharacterVersion(CampaignStateSnapshot snapshot, Guid? characterId)
@@ -108,13 +96,4 @@ public sealed class WorkspaceLiveStateController
return snapshot.CharacterVersions.FirstOrDefault(version => version.CharacterId == characterId.Value)?.Version ?? 0;
}
private readonly WorkspaceFeedbackService m_Feedback;
private readonly Func<Guid?, Task> m_RefreshCampaignLogAsync;
private readonly Func<Task> m_RefreshCampaignRosterAsync;
private readonly Func<Task> m_RefreshSelectedCharacterSheetAsync;
private readonly Func<Task> m_RequestRefreshAsync;
private readonly Func<Guid, Task> m_StartStateEventsAsync;
private readonly WorkspaceState m_State;
private readonly Func<Task> m_StopStateEventsCoreAsync;
}

View File

@@ -4,54 +4,44 @@ using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class WorkspacePlayCoordinator
public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<CharacterSummary, bool> canEditCharacter, Func<Task> requestRefreshAsync)
{
public WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<CharacterSummary, bool> canEditCharacter, Func<Task> requestRefreshAsync)
{
m_State = state;
m_Feedback = feedback;
m_ApiClient = apiClient;
m_WorkspaceQuery = workspaceQuery;
m_CanEditCharacter = canEditCharacter;
m_RequestRefreshAsync = requestRefreshAsync;
}
public async Task RefreshCampaignLogAsync(Guid? afterRollId = null)
{
if (!m_State.SelectedCampaignId.HasValue || !m_State.IsPlayScreen)
if (!state.SelectedCampaignId.HasValue || !state.IsPlayScreen)
{
m_State.CampaignLog = [];
m_State.CampaignLogCursor = null;
state.CampaignLog = [];
state.CampaignLogCursor = null;
ResetCampaignLogDetailState();
return;
}
var previousLogCount = m_State.CampaignLog.Count;
var page = await m_WorkspaceQuery.GetCampaignLogPageAsync(m_State.SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize);
var previousLogCount = state.CampaignLog.Count;
var page = await workspaceQuery.GetCampaignLogPageAsync(state.SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize);
Guid? newestRollId = null;
if (!afterRollId.HasValue || page.ResetRequired)
m_State.CampaignLog = page.Entries.ToList();
state.CampaignLog = page.Entries.ToList();
else if (page.Entries.Length > 0)
{
m_State.CampaignLog.AddRange(page.Entries);
if (m_State.CampaignLog.Count > CampaignLogWindowSize)
m_State.CampaignLog = m_State.CampaignLog.TakeLast(CampaignLogWindowSize).ToList();
state.CampaignLog.AddRange(page.Entries);
if (state.CampaignLog.Count > CampaignLogWindowSize)
state.CampaignLog = state.CampaignLog.TakeLast(CampaignLogWindowSize).ToList();
}
var shouldAutoExpandNewest = afterRollId.HasValue && page.Entries.Length > 0;
if (!shouldAutoExpandNewest && !afterRollId.HasValue && m_State.CurrentCampaignState is not null && previousLogCount == 0 && page.Entries.Length > 0)
if (!shouldAutoExpandNewest && !afterRollId.HasValue && state.CurrentCampaignState is not null && previousLogCount == 0 && page.Entries.Length > 0)
shouldAutoExpandNewest = true;
if (shouldAutoExpandNewest)
{
newestRollId = page.Entries[^1].RollId;
m_State.ExpandedCampaignLogRollId = newestRollId;
m_State.FreshCampaignLogRollId = newestRollId;
state.ExpandedCampaignLogRollId = newestRollId;
state.FreshCampaignLogRollId = newestRollId;
}
else if (!afterRollId.HasValue)
m_State.FreshCampaignLogRollId = null;
state.FreshCampaignLogRollId = null;
m_State.CampaignLogCursor = page.Cursor ?? afterRollId;
state.CampaignLogCursor = page.Cursor ?? afterRollId;
TrimCampaignLogDetails();
if (newestRollId.HasValue)
@@ -60,23 +50,23 @@ public sealed class WorkspacePlayCoordinator
public async Task SelectCharacterAsync(Guid characterId)
{
m_State.SelectedCharacterId = characterId;
state.SelectedCharacterId = characterId;
await RefreshSelectedCharacterSheetAsync();
await EnsureSelectedCharacterActiveAsync();
}
public async Task RefreshSelectedCharacterSheetAsync()
{
if (!m_State.SelectedCharacterId.HasValue || m_State.SelectedCampaign is null || !m_State.IsPlayScreen)
if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null || !state.IsPlayScreen)
{
m_State.SelectedCharacterSkills = [];
m_State.SelectedCharacterSkillGroups = [];
state.SelectedCharacterSkills = [];
state.SelectedCharacterSkillGroups = [];
return;
}
var sheet = await m_WorkspaceQuery.GetCharacterSheetAsync(m_State.SelectedCharacterId.Value);
m_State.SelectedCharacterSkillGroups = sheet.SkillGroups.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList();
m_State.SelectedCharacterSkills = sheet.Skills.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
var sheet = await workspaceQuery.GetCharacterSheetAsync(state.SelectedCharacterId.Value);
state.SelectedCharacterSkillGroups = sheet.SkillGroups.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList();
state.SelectedCharacterSkills = sheet.Skills.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
public Task EnsureSelectedCharacterActiveAsync()
@@ -86,16 +76,16 @@ public sealed class WorkspacePlayCoordinator
public async Task ToggleRollDetailAsync(Guid rollId)
{
if (m_State.ExpandedCampaignLogRollId == rollId)
if (state.ExpandedCampaignLogRollId == rollId)
{
m_State.ExpandedCampaignLogRollId = null;
if (m_State.FreshCampaignLogRollId == rollId)
m_State.FreshCampaignLogRollId = null;
state.ExpandedCampaignLogRollId = null;
if (state.FreshCampaignLogRollId == rollId)
state.FreshCampaignLogRollId = null;
return;
}
m_State.ExpandedCampaignLogRollId = rollId;
m_State.FreshCampaignLogRollId = null;
state.ExpandedCampaignLogRollId = rollId;
state.FreshCampaignLogRollId = null;
await EnsureRollDetailLoadedAsync(rollId);
}
@@ -103,212 +93,212 @@ public sealed class WorkspacePlayCoordinator
{
await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking();
m_Feedback.SetStatus("Skill created.", false);
feedback.SetStatus("Skill created.", false);
}
public async Task OnSkillUpdatedAsync(Guid _)
{
await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking();
m_Feedback.SetStatus("Skill updated.", false);
feedback.SetStatus("Skill updated.", false);
}
public async Task OnSkillGroupCreatedAsync(Guid _)
{
await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking();
m_Feedback.SetStatus("Skill group created.", false);
feedback.SetStatus("Skill group created.", false);
}
public async Task OnSkillGroupUpdatedAsync(Guid _)
{
await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking();
m_Feedback.SetStatus("Skill group updated.", false);
feedback.SetStatus("Skill group updated.", false);
}
public async Task OnSkillDeletedAsync(Guid _)
{
await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking();
m_Feedback.SetStatus("Skill deleted.", false);
feedback.SetStatus("Skill deleted.", false);
}
public async Task OnSkillGroupDeletedAsync(Guid _)
{
await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking();
m_Feedback.SetStatus("Skill group deleted.", false);
feedback.SetStatus("Skill group deleted.", false);
}
public Task OnCharacterPanelErrorAsync(string message)
{
m_Feedback.SetStatus(message, true);
feedback.SetStatus(message, true);
return Task.CompletedTask;
}
public Task OnCampaignLogPanelErrorAsync(string message)
{
m_Feedback.SetStatus(message, true);
feedback.SetStatus(message, true);
return Task.CompletedTask;
}
public async Task RollSkillAsync(Guid skillId)
{
if (m_State.SelectedCampaign is null)
if (state.SelectedCampaign is null)
{
m_Feedback.SetStatus("No campaign selected.", true);
feedback.SetStatus("No campaign selected.", true);
return;
}
m_State.IsMutating = true;
state.IsMutating = true;
try
{
var roll = await m_ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(m_State.RollVisibility));
var roll = await apiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(state.RollVisibility));
await HandleRecordedRollAsync(roll);
}
catch (ApiRequestException ex)
{
m_Feedback.SetStatus(ex.Message, true);
feedback.SetStatus(ex.Message, true);
}
finally
{
m_State.IsMutating = false;
state.IsMutating = false;
}
}
public async Task OnCustomRollCreatedAsync(RollResult roll)
{
m_State.IsMutating = true;
state.IsMutating = true;
try
{
await HandleRecordedRollAsync(roll);
}
finally
{
m_State.IsMutating = false;
state.IsMutating = false;
}
}
public bool CanEditSkill(CharacterSheetSkill skill)
{
if (m_State.SelectedCharacter is null)
if (state.SelectedCharacter is null)
return false;
return m_CanEditCharacter(m_State.SelectedCharacter);
return canEditCharacter(state.SelectedCharacter);
}
public CampaignRollDetail? ResolveRollDetail(Guid rollId)
{
return m_State.CampaignLogDetails.GetValueOrDefault(rollId);
return state.CampaignLogDetails.GetValueOrDefault(rollId);
}
public bool IsRollDetailLoading(Guid rollId)
{
return m_State.CampaignLogDetailsLoading.Contains(rollId);
return state.CampaignLogDetailsLoading.Contains(rollId);
}
public string? GetRollDetailError(Guid rollId)
{
return m_State.CampaignLogDetailErrors.GetValueOrDefault(rollId);
return state.CampaignLogDetailErrors.GetValueOrDefault(rollId);
}
public void ResetCampaignLogDetailState()
{
m_State.ExpandedCampaignLogRollId = null;
m_State.FreshCampaignLogRollId = null;
m_State.CampaignLogDetails.Clear();
m_State.CampaignLogDetailsLoading.Clear();
m_State.CampaignLogDetailErrors.Clear();
state.ExpandedCampaignLogRollId = null;
state.FreshCampaignLogRollId = null;
state.CampaignLogDetails.Clear();
state.CampaignLogDetailsLoading.Clear();
state.CampaignLogDetailErrors.Clear();
}
public void ResetCampaignStateTracking()
{
m_State.CurrentCampaignState = null;
state.CurrentCampaignState = null;
}
private async Task EnsureSelectedCharacterActiveCoreAsync()
{
if (!m_State.SelectedCharacterId.HasValue || m_State.SelectedCampaign is null)
if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null)
return;
var character = m_State.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == m_State.SelectedCharacterId.Value);
if (character is null || !CanActivateCharacter(character, m_State.User) || m_State.ActiveCharacterId == character.Id)
var character = state.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == state.SelectedCharacterId.Value);
if (character is null || !CanActivateCharacter(character, state.User) || state.ActiveCharacterId == character.Id)
return;
try
{
await m_ApiClient.RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate");
m_State.ActiveCharacterId = character.Id;
await apiClient.RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate");
state.ActiveCharacterId = character.Id;
}
catch (ApiRequestException ex)
{
m_Feedback.SetStatus(ex.Message, true);
feedback.SetStatus(ex.Message, true);
}
}
private async Task HandleRecordedRollAsync(RollResult roll)
{
m_State.LastRoll = roll;
m_State.CampaignLogDetails[roll.RollId] = ToCampaignRollDetail(roll);
m_State.CampaignLogDetailErrors.Remove(roll.RollId);
state.LastRoll = roll;
state.CampaignLogDetails[roll.RollId] = ToCampaignRollDetail(roll);
state.CampaignLogDetailErrors.Remove(roll.RollId);
await RefreshCampaignLogAsync(m_State.CampaignLogCursor);
await RefreshCampaignLogAsync(state.CampaignLogCursor);
PromoteFreshRoll(roll.RollId);
ResetCampaignStateTracking();
m_Feedback.SetStatus("Roll recorded.", false);
m_Feedback.Announce("Roll result updated.");
feedback.SetStatus("Roll recorded.", false);
feedback.Announce("Roll result updated.");
}
private void TrimCampaignLogDetails()
{
var visibleRollIds = m_State.CampaignLog.Select(entry => entry.RollId).ToHashSet();
var visibleRollIds = state.CampaignLog.Select(entry => entry.RollId).ToHashSet();
foreach (var rollId in m_State.CampaignLogDetails.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
m_State.CampaignLogDetails.Remove(rollId);
foreach (var rollId in state.CampaignLogDetails.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
state.CampaignLogDetails.Remove(rollId);
foreach (var rollId in m_State.CampaignLogDetailsLoading.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
m_State.CampaignLogDetailsLoading.Remove(rollId);
foreach (var rollId in state.CampaignLogDetailsLoading.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
state.CampaignLogDetailsLoading.Remove(rollId);
foreach (var rollId in m_State.CampaignLogDetailErrors.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
m_State.CampaignLogDetailErrors.Remove(rollId);
foreach (var rollId in state.CampaignLogDetailErrors.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
state.CampaignLogDetailErrors.Remove(rollId);
if (m_State.ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(m_State.ExpandedCampaignLogRollId.Value))
m_State.ExpandedCampaignLogRollId = null;
if (state.ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(state.ExpandedCampaignLogRollId.Value))
state.ExpandedCampaignLogRollId = null;
if (m_State.FreshCampaignLogRollId.HasValue && !visibleRollIds.Contains(m_State.FreshCampaignLogRollId.Value))
m_State.FreshCampaignLogRollId = null;
if (state.FreshCampaignLogRollId.HasValue && !visibleRollIds.Contains(state.FreshCampaignLogRollId.Value))
state.FreshCampaignLogRollId = null;
}
private async Task EnsureRollDetailLoadedAsync(Guid rollId)
{
m_State.CampaignLogDetailErrors.Remove(rollId);
if (m_State.CampaignLogDetails.ContainsKey(rollId) || m_State.CampaignLogDetailsLoading.Contains(rollId))
state.CampaignLogDetailErrors.Remove(rollId);
if (state.CampaignLogDetails.ContainsKey(rollId) || state.CampaignLogDetailsLoading.Contains(rollId))
return;
m_State.CampaignLogDetailsLoading.Add(rollId);
state.CampaignLogDetailsLoading.Add(rollId);
try
{
m_State.CampaignLogDetails[rollId] = await m_WorkspaceQuery.GetRollDetailAsync(rollId);
state.CampaignLogDetails[rollId] = await workspaceQuery.GetRollDetailAsync(rollId);
}
catch (ApiRequestException ex)
{
m_State.CampaignLogDetailErrors[rollId] = ex.Message;
state.CampaignLogDetailErrors[rollId] = ex.Message;
}
finally
{
m_State.CampaignLogDetailsLoading.Remove(rollId);
await m_RequestRefreshAsync();
state.CampaignLogDetailsLoading.Remove(rollId);
await requestRefreshAsync();
}
}
private void PromoteFreshRoll(Guid rollId)
{
if (!m_State.CampaignLog.Any(entry => entry.RollId == rollId))
if (!state.CampaignLog.Any(entry => entry.RollId == rollId))
return;
m_State.ExpandedCampaignLogRollId = rollId;
m_State.FreshCampaignLogRollId = rollId;
state.ExpandedCampaignLogRollId = rollId;
state.FreshCampaignLogRollId = rollId;
}
private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user)
@@ -322,11 +312,4 @@ public sealed class WorkspacePlayCoordinator
}
private const int CampaignLogWindowSize = 25;
private readonly RpgRollerApiClient m_ApiClient;
private readonly Func<CharacterSummary, bool> m_CanEditCharacter;
private readonly WorkspaceFeedbackService m_Feedback;
private readonly Func<Task> m_RequestRefreshAsync;
private readonly WorkspaceState m_State;
private readonly WorkspaceQueryService m_WorkspaceQuery;
}

View File

@@ -3,40 +3,22 @@ using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
public sealed class WorkspaceSessionCoordinator
public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> syncStateEventsAsync, Func<Task> stopStateEventsAsync, Func<Task> ensureAdminUsersLoadedAsync, Action resetCampaignLogDetailState, Func<Task> requestRefreshAsync, Func<string?, Task> onLoggedOutAsync)
{
public WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> syncStateEventsAsync, Func<Task> stopStateEventsAsync, Func<Task> ensureAdminUsersLoadedAsync, Action resetCampaignLogDetailState, Func<Task> requestRefreshAsync, Func<string?, Task> onLoggedOutAsync)
{
m_State = state;
m_Feedback = feedback;
m_JS = js;
m_ApiClient = apiClient;
m_WorkspaceQuery = workspaceQuery;
m_ReloadCampaignsAsync = reloadCampaignsAsync;
m_ReloadCharacterCampaignOptionsAsync = reloadCharacterCampaignOptionsAsync;
m_RefreshCampaignScopeAsync = refreshCampaignScopeAsync;
m_SyncStateEventsAsync = syncStateEventsAsync;
m_StopStateEventsAsync = stopStateEventsAsync;
m_EnsureAdminUsersLoadedAsync = ensureAdminUsersLoadedAsync;
m_ResetCampaignLogDetailState = resetCampaignLogDetailState;
m_RequestRefreshAsync = requestRefreshAsync;
m_OnLoggedOutAsync = onLoggedOutAsync;
}
public async Task InitializeAsync()
{
var storedScreen = await m_JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
m_State.CurrentScreen = NormalizeRequestedScreen(storedScreen) ?? ScreenPlay;
var storedScreen = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
state.CurrentScreen = NormalizeRequestedScreen(storedScreen) ?? ScreenPlay;
var storedPanel = await m_JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey);
var storedPanel = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey);
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
m_State.MobilePanel = "log";
state.MobilePanel = "log";
var storedRollVisibility = await m_JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", RollVisibilitySessionKey);
m_State.RollVisibility = NormalizeRollVisibility(storedRollVisibility);
var storedRollVisibility = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", RollVisibilitySessionKey);
state.RollVisibility = NormalizeRollVisibility(storedRollVisibility);
Guid? preferredCampaignId = null;
var storedCampaignId = await m_JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", CampaignSessionKey);
var storedCampaignId = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", CampaignSessionKey);
if (Guid.TryParse(storedCampaignId, out var parsedCampaignId))
preferredCampaignId = parsedCampaignId;
@@ -45,17 +27,17 @@ public sealed class WorkspaceSessionCoordinator
var reloaded = await ReloadAuthenticatedSessionAsync(preferredCampaignId);
if (!reloaded)
await m_OnLoggedOutAsync("Session expired. Please log in again.");
await onLoggedOutAsync("Session expired. Please log in again.");
}
public async Task RetryAfterHealthIssueAsync()
{
await CheckHealthAsync();
if (!m_State.HasHealthIssue && m_State.User is not null)
if (!state.HasHealthIssue && state.User is not null)
{
var reloaded = await ReloadAuthenticatedSessionAsync(m_State.SelectedCampaignId);
var reloaded = await ReloadAuthenticatedSessionAsync(state.SelectedCampaignId);
if (!reloaded)
await m_OnLoggedOutAsync("Session expired. Please log in again.");
await onLoggedOutAsync("Session expired. Please log in again.");
}
}
@@ -63,102 +45,102 @@ public sealed class WorkspaceSessionCoordinator
{
try
{
var usernames = await m_WorkspaceQuery.GetUsernamesAsync();
m_State.KnownUsernames = usernames.OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToList();
var usernames = await workspaceQuery.GetUsernamesAsync();
state.KnownUsernames = usernames.OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToList();
}
catch (ApiRequestException ex)
{
m_State.KnownUsernames = [];
m_Feedback.SetStatus(ex.Message, true);
state.KnownUsernames = [];
feedback.SetStatus(ex.Message, true);
}
}
public async Task LogoutAsync()
{
if (m_State.IsMutating)
if (state.IsMutating)
return;
m_State.IsMutating = true;
state.IsMutating = true;
try
{
await m_ApiClient.RequestWithoutPayloadAsync("POST", "/api/auth/logout");
await apiClient.RequestWithoutPayloadAsync("POST", "/api/auth/logout");
}
catch (ApiRequestException)
{
}
finally
{
m_State.IsMutating = false;
state.IsMutating = false;
}
ClearAuthenticatedState();
await m_StopStateEventsAsync();
await m_OnLoggedOutAsync("Logged out.");
await stopStateEventsAsync();
await onLoggedOutAsync("Logged out.");
}
public async Task SwitchScreenAsync(string screen)
{
var targetScreen = NormalizeRequestedScreen(screen) ?? ScreenPlay;
if (string.Equals(targetScreen, ScreenAdmin, StringComparison.OrdinalIgnoreCase) && !m_State.IsCurrentUserAdmin)
if (string.Equals(targetScreen, ScreenAdmin, StringComparison.OrdinalIgnoreCase) && !state.IsCurrentUserAdmin)
targetScreen = ScreenPlay;
m_State.CurrentScreen = targetScreen;
m_State.IsScreenMenuOpen = false;
await PersistScreenPreferenceAsync(m_State.CurrentScreen);
await m_RequestRefreshAsync();
state.CurrentScreen = targetScreen;
state.IsScreenMenuOpen = false;
await PersistScreenPreferenceAsync(state.CurrentScreen);
await requestRefreshAsync();
if (m_State.User is not null)
if (state.User is not null)
{
await m_RefreshCampaignScopeAsync();
await m_SyncStateEventsAsync();
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
}
if (m_State.IsAdminScreen)
if (state.IsAdminScreen)
{
await m_EnsureAdminUsersLoadedAsync();
await m_RequestRefreshAsync();
await ensureAdminUsersLoadedAsync();
await requestRefreshAsync();
}
}
public async Task OnRollVisibilityChangedAsync(string visibility)
{
m_State.RollVisibility = NormalizeRollVisibility(visibility);
await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", RollVisibilitySessionKey, m_State.RollVisibility);
state.RollVisibility = NormalizeRollVisibility(visibility);
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", RollVisibilitySessionKey, state.RollVisibility);
}
public void ClearAuthenticatedState()
{
m_State.User = null;
m_State.ActiveCharacterId = null;
m_State.SelectedCampaignId = null;
m_State.SelectedCampaign = null;
m_State.Campaigns = [];
m_State.CharacterCampaignOptions = [];
m_State.SelectedCharacterSkills = [];
m_State.SelectedCharacterSkillGroups = [];
m_State.CampaignLog = [];
m_State.CampaignLogCursor = null;
m_ResetCampaignLogDetailState();
m_State.SelectedCharacterId = null;
m_State.LastRoll = null;
m_State.KnownUsernames = [];
m_State.ShowCreateCharacterModal = false;
m_State.ShowEditCharacterModal = false;
m_State.CanEditCharacterOwner = false;
m_State.CreateCharacterInitialModel = new();
m_State.EditCharacterInitialModel = new();
m_State.CreateCharacterFormVersion = 0;
m_State.EditCharacterFormVersion = 0;
m_State.AdminUsers = [];
m_State.HasLoadedAdminUsers = false;
m_State.IsAdminDataLoading = false;
m_Feedback.ClearToasts();
state.User = null;
state.ActiveCharacterId = null;
state.SelectedCampaignId = null;
state.SelectedCampaign = null;
state.Campaigns = [];
state.CharacterCampaignOptions = [];
state.SelectedCharacterSkills = [];
state.SelectedCharacterSkillGroups = [];
state.CampaignLog = [];
state.CampaignLogCursor = null;
resetCampaignLogDetailState();
state.SelectedCharacterId = null;
state.LastRoll = null;
state.KnownUsernames = [];
state.ShowCreateCharacterModal = false;
state.ShowEditCharacterModal = false;
state.CanEditCharacterOwner = false;
state.CreateCharacterInitialModel = new();
state.EditCharacterInitialModel = new();
state.CreateCharacterFormVersion = 0;
state.EditCharacterFormVersion = 0;
state.AdminUsers = [];
state.HasLoadedAdminUsers = false;
state.IsAdminDataLoading = false;
feedback.ClearToasts();
}
private async Task CheckHealthAsync()
{
m_State.HasHealthIssue = false;
m_State.HealthIssueMessage = string.Empty;
state.HasHealthIssue = false;
state.HealthIssueMessage = string.Empty;
await Task.CompletedTask;
}
@@ -166,11 +148,11 @@ public sealed class WorkspaceSessionCoordinator
{
try
{
m_State.Rulesets = (await m_WorkspaceQuery.GetRulesetsAsync()).ToList();
state.Rulesets = (await workspaceQuery.GetRulesetsAsync()).ToList();
}
catch (ApiRequestException ex)
{
m_Feedback.SetStatus(ex.Message, true);
feedback.SetStatus(ex.Message, true);
}
}
@@ -180,21 +162,21 @@ public sealed class WorkspaceSessionCoordinator
if (me is null)
{
ClearAuthenticatedState();
await m_StopStateEventsAsync();
await stopStateEventsAsync();
return false;
}
m_State.User = me.User;
m_State.ActiveCharacterId = me.ActiveCharacterId;
state.User = me.User;
state.ActiveCharacterId = me.ActiveCharacterId;
await EnsureScreenAccessAsync();
await m_ReloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId);
await m_ReloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync();
await m_SyncStateEventsAsync();
await reloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId);
await reloadCharacterCampaignOptionsAsync();
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
if (m_State.IsAdminScreen)
await m_EnsureAdminUsersLoadedAsync();
if (state.IsAdminScreen)
await ensureAdminUsersLoadedAsync();
return true;
}
@@ -203,7 +185,7 @@ public sealed class WorkspaceSessionCoordinator
{
try
{
return await m_WorkspaceQuery.GetMeAsync();
return await workspaceQuery.GetMeAsync();
}
catch (ApiRequestException ex) when (ex.StatusCode == 401)
{
@@ -213,24 +195,24 @@ public sealed class WorkspaceSessionCoordinator
private async Task EnsureScreenAccessAsync()
{
if (m_State.IsCurrentUserAdmin)
if (state.IsCurrentUserAdmin)
return;
m_State.AdminUsers = [];
m_State.HasLoadedAdminUsers = false;
state.AdminUsers = [];
state.HasLoadedAdminUsers = false;
if (!m_State.IsAdminScreen)
if (!state.IsAdminScreen)
return;
m_State.CurrentScreen = ScreenPlay;
await PersistScreenPreferenceAsync(m_State.CurrentScreen);
state.CurrentScreen = ScreenPlay;
await PersistScreenPreferenceAsync(state.CurrentScreen);
}
private async Task PersistScreenPreferenceAsync(string screen)
{
try
{
await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, screen);
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, screen);
}
catch (JSDisconnectedException)
{
@@ -271,19 +253,4 @@ public sealed class WorkspaceSessionCoordinator
private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel";
private const string RollVisibilitySessionKey = "roll-visibility";
private readonly RpgRollerApiClient m_ApiClient;
private readonly Func<Task> m_EnsureAdminUsersLoadedAsync;
private readonly WorkspaceFeedbackService m_Feedback;
private readonly IJSRuntime m_JS;
private readonly Func<string?, Task> m_OnLoggedOutAsync;
private readonly Func<Task> m_RefreshCampaignScopeAsync;
private readonly Func<Guid?, Task> m_ReloadCampaignsAsync;
private readonly Func<Task> m_ReloadCharacterCampaignOptionsAsync;
private readonly Func<Task> m_RequestRefreshAsync;
private readonly Action m_ResetCampaignLogDetailState;
private readonly WorkspaceState m_State;
private readonly Func<Task> m_StopStateEventsAsync;
private readonly Func<Task> m_SyncStateEventsAsync;
private readonly WorkspaceQueryService m_WorkspaceQuery;
}

View File

@@ -4,7 +4,7 @@ using RpgRoller.Contracts;
namespace RpgRoller.Components;
public sealed class RpgRollerApiClient
public sealed class RpgRollerApiClient(IJSRuntime js)
{
private sealed class JsApiResponse
{
@@ -15,14 +15,9 @@ public sealed class RpgRollerApiClient
public JsonElement Data { get; set; }
}
public RpgRollerApiClient(IJSRuntime js)
{
m_Js = js;
}
public async Task<T> RequestAsync<T>(string method, string path, object? payload = null)
{
var response = await m_Js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, payload);
var response = await js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, payload);
if (!response.Ok)
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.", response.Code);
@@ -34,23 +29,16 @@ public sealed class RpgRollerApiClient
public async Task RequestWithoutPayloadAsync(string method, string path)
{
var response = await m_Js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, null);
var response = await js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, null);
if (!response.Ok)
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.", response.Code);
}
private static readonly JsonSerializerOptions JsonOptions = RpgRollerJson.CreateSerializerOptions();
private readonly IJSRuntime m_Js;
}
public sealed class ApiRequestException : Exception
public sealed class ApiRequestException(int statusCode, string message, string? errorCode = null) : Exception(message)
{
public ApiRequestException(int statusCode, string message, string? errorCode = null) : base(message)
{
StatusCode = statusCode;
ErrorCode = errorCode;
}
public int StatusCode { get; }
public string? ErrorCode { get; }
public int StatusCode { get; } = statusCode;
public string? ErrorCode { get; } = errorCode;
}

View File

@@ -3,72 +3,66 @@ using RpgRoller.Services;
namespace RpgRoller.Components;
public sealed class WorkspaceQueryService
public sealed class WorkspaceQueryService(IGameService gameService, WorkspaceSessionTokenAccessor sessionTokenAccessor)
{
public WorkspaceQueryService(IGameService gameService, WorkspaceSessionTokenAccessor sessionTokenAccessor)
{
m_GameService = gameService;
m_SessionTokenAccessor = sessionTokenAccessor;
}
public Task<MeResponse> GetMeAsync()
{
return Task.FromResult(GetValue(m_GameService.GetMe(GetRequiredSessionToken())));
return Task.FromResult(GetValue(gameService.GetMe(GetRequiredSessionToken())));
}
public Task<IReadOnlyList<RulesetDefinition>> GetRulesetsAsync()
{
return Task.FromResult(m_GameService.GetRulesets());
return Task.FromResult(gameService.GetRulesets());
}
public Task<IReadOnlyList<CampaignSummary>> GetCampaignsAsync()
{
return Task.FromResult(GetValue(m_GameService.GetCampaigns(GetRequiredSessionToken())));
return Task.FromResult(GetValue(gameService.GetCampaigns(GetRequiredSessionToken())));
}
public Task<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptionsAsync()
{
return Task.FromResult(GetValue(m_GameService.GetCharacterCampaignOptions(GetRequiredSessionToken())));
return Task.FromResult(GetValue(gameService.GetCharacterCampaignOptions(GetRequiredSessionToken())));
}
public Task<CampaignRoster> GetCampaignAsync(Guid campaignId)
{
return Task.FromResult(GetValue(m_GameService.GetCampaign(GetRequiredSessionToken(), campaignId)));
return Task.FromResult(GetValue(gameService.GetCampaign(GetRequiredSessionToken(), campaignId)));
}
public Task<IReadOnlyList<string>> GetUsernamesAsync()
{
return Task.FromResult(GetValue(m_GameService.GetUsernames(GetRequiredSessionToken())));
return Task.FromResult(GetValue(gameService.GetUsernames(GetRequiredSessionToken())));
}
public Task<CharacterSheet> GetCharacterSheetAsync(Guid characterId)
{
return Task.FromResult(GetValue(m_GameService.GetCharacterSheet(GetRequiredSessionToken(), characterId)));
return Task.FromResult(GetValue(gameService.GetCharacterSheet(GetRequiredSessionToken(), characterId)));
}
public Task<IReadOnlyList<CampaignLogEntry>> GetCampaignLogAsync(Guid campaignId)
{
return Task.FromResult(GetValue(m_GameService.GetCampaignLog(GetRequiredSessionToken(), campaignId)));
return Task.FromResult(GetValue(gameService.GetCampaignLog(GetRequiredSessionToken(), campaignId)));
}
public Task<CampaignLogPage> GetCampaignLogPageAsync(Guid campaignId, Guid? afterRollId = null, int? limit = null)
{
return Task.FromResult(GetValue(m_GameService.GetCampaignLogPage(GetRequiredSessionToken(), campaignId, afterRollId, limit)));
return Task.FromResult(GetValue(gameService.GetCampaignLogPage(GetRequiredSessionToken(), campaignId, afterRollId, limit)));
}
public Task<CampaignRollDetail> GetRollDetailAsync(Guid rollId)
{
return Task.FromResult(GetValue(m_GameService.GetRollDetail(GetRequiredSessionToken(), rollId)));
return Task.FromResult(GetValue(gameService.GetRollDetail(GetRequiredSessionToken(), rollId)));
}
public Task<IReadOnlyList<AdminUserSummary>> GetAdminUsersAsync()
{
return Task.FromResult(GetValue(m_GameService.GetUsers(GetRequiredSessionToken())));
return Task.FromResult(GetValue(gameService.GetUsers(GetRequiredSessionToken())));
}
private string GetRequiredSessionToken()
{
return m_SessionTokenAccessor.GetRequiredSessionToken();
return sessionTokenAccessor.GetRequiredSessionToken();
}
private static T GetValue<T>(ServiceResult<T> result)
@@ -84,7 +78,4 @@ public sealed class WorkspaceQueryService
var statusCode = error.Code == "unauthorized" ? 401 : 400;
return new(statusCode, error.Message, error.Code);
}
private readonly IGameService m_GameService;
private readonly WorkspaceSessionTokenAccessor m_SessionTokenAccessor;
}

View File

@@ -3,12 +3,8 @@ using RpgRoller.Domain;
namespace RpgRoller.Data;
public sealed class RpgRollerDbContext : DbContext
public sealed class RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> options) : DbContext(options)
{
public RpgRollerDbContext(DbContextOptions<RpgRollerDbContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<UserAccount>(entity =>

View File

@@ -23,8 +23,8 @@ public static class CampaignLogSummaryBuilder
switch (ruleset)
{
case RulesetKind.D6:
AddBadgeIfMissing(badges, dice.Any(die => die.Wild && die.Roll == 6), "w6");
AddBadgeIfMissing(badges, dice.Any(die => die.Wild && die.Roll == 1), "w1");
AddBadgeIfMissing(badges, dice.Any(die => die is { Wild: true, Roll: 6 }), "w6");
AddBadgeIfMissing(badges, dice.Any(die => die is { Wild: true, Roll: 1 }), "w1");
break;
case RulesetKind.Dnd5e:
if (!string.IsNullOrWhiteSpace(expression) && IsSingleD20Expression(expression))

View File

@@ -3,13 +3,8 @@ using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class D6RollEngine
public sealed class D6RollEngine(IDiceRoller diceRoller)
{
public D6RollEngine(IDiceRoller diceRoller)
{
m_DiceRoller = diceRoller;
}
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int wildDice, bool allowFumble)
{
var initialDice = expression.DiceCount;
@@ -20,7 +15,7 @@ public sealed class D6RollEngine
for (var i = 0; i < currentDice; i += 1)
{
var roll = m_DiceRoller.Roll(expression.Sides);
var roll = diceRoller.Roll(expression.Sides);
var isWild = i < wildDice;
var isCrit = false;
var isFumble = false;
@@ -89,6 +84,4 @@ public sealed class D6RollEngine
return (total, RollBreakdownFormatter.BuildBreakdown(includedDice, expression.Modifier, total), dieResults);
}
private readonly IDiceRoller m_DiceRoller;
}

View File

@@ -4,15 +4,8 @@ using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class GameAuthService
public sealed class GameAuthService(GameStateStore stateStore, IPasswordHasher<UserAccount> passwordHasher, GamePersistenceService persistenceService)
{
public GameAuthService(GameStateStore stateStore, IPasswordHasher<UserAccount> passwordHasher, GamePersistenceService persistenceService)
{
m_StateStore = stateStore;
m_PasswordHasher = passwordHasher;
m_PersistenceService = persistenceService;
}
public ServiceResult<UserSummary> Register(string username, string password, string displayName)
{
if (string.IsNullOrWhiteSpace(username))
@@ -24,11 +17,11 @@ public sealed class GameAuthService
if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
return ServiceResult<UserSummary>.Failure("invalid_password", "Password must be at least 8 characters.");
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var trimmedUsername = username.Trim();
var normalizedUsername = NormalizeUsername(trimmedUsername);
if (m_StateStore.UserIdsByUsername.ContainsKey(normalizedUsername))
if (stateStore.UserIdsByUsername.ContainsKey(normalizedUsername))
return ServiceResult<UserSummary>.Failure("duplicate_username", "Username is already taken.");
var user = new UserAccount
@@ -38,16 +31,16 @@ public sealed class GameAuthService
UsernameNormalized = normalizedUsername,
DisplayName = displayName.Trim(),
PasswordHash = string.Empty,
Roles = m_StateStore.UsersById.Count == 0 ? UserRoles.Admin : string.Empty,
Roles = stateStore.UsersById.Count == 0 ? UserRoles.Admin : string.Empty,
ActiveCharacterId = null
};
user.PasswordHash = m_PasswordHasher.HashPassword(user, password);
user.PasswordHash = passwordHasher.HashPassword(user, password);
m_StateStore.UsersById[user.Id] = user;
m_StateStore.UserIdsByUsername[user.UsernameNormalized] = user.Id;
stateStore.UsersById[user.Id] = user;
stateStore.UserIdsByUsername[user.UsernameNormalized] = user.Id;
m_PersistenceService.PersistStateLocked();
persistenceService.PersistStateLocked();
return ServiceResult<UserSummary>.Success(GameDtoMapper.ToUserSummary(user));
}
}
@@ -57,59 +50,59 @@ public sealed class GameAuthService
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var normalizedUsername = NormalizeUsername(username.Trim());
if (!m_StateStore.UserIdsByUsername.TryGetValue(normalizedUsername, out var userId))
if (!stateStore.UserIdsByUsername.TryGetValue(normalizedUsername, out var userId))
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
var user = m_StateStore.UsersById[userId];
var verification = m_PasswordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
var user = stateStore.UsersById[userId];
var verification = 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);
user.PasswordHash = passwordHasher.HashPassword(user, password);
var session = CreateSession(userId);
m_PersistenceService.PersistStateLocked();
persistenceService.PersistStateLocked();
return ServiceResult<(UserSummary User, string SessionToken)>.Success((GameDtoMapper.ToUserSummary(user), session.Token));
}
}
public void Logout(string sessionToken)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
if (m_StateStore.SessionsByToken.Remove(sessionToken))
m_PersistenceService.PersistStateLocked();
if (stateStore.SessionsByToken.Remove(sessionToken))
persistenceService.PersistStateLocked();
}
}
public UserSummary? GetUserBySession(string sessionToken)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
return user is null ? null : GameDtoMapper.ToUserSummary(user);
}
}
public ServiceResult<MeResponse> GetMe(string sessionToken)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.");
Guid? campaignId = null;
if (user.ActiveCharacterId is Guid activeCharacterId)
{
if (!m_StateStore.CharactersById.TryGetValue(activeCharacterId, out var activeCharacter))
if (!stateStore.CharactersById.TryGetValue(activeCharacterId, out var activeCharacter))
{
user.ActiveCharacterId = null;
m_PersistenceService.PersistStateLocked();
persistenceService.PersistStateLocked();
}
else
campaignId = activeCharacter.CampaignId;
@@ -129,7 +122,7 @@ public sealed class GameAuthService
CreatedAtUtc = DateTimeOffset.UtcNow
};
m_StateStore.SessionsByToken[token] = session;
stateStore.SessionsByToken[token] = session;
return session;
}
@@ -137,8 +130,4 @@ public sealed class GameAuthService
{
return username.ToUpperInvariant();
}
private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
private readonly GamePersistenceService m_PersistenceService;
private readonly GameStateStore m_StateStore;
}

View File

@@ -3,14 +3,8 @@ using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class GameCampaignService
public sealed class GameCampaignService(GameStateStore stateStore, GamePersistenceService persistenceService)
{
public GameCampaignService(GameStateStore stateStore, GamePersistenceService persistenceService)
{
m_StateStore = stateStore;
m_PersistenceService = persistenceService;
}
public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
{
if (string.IsNullOrWhiteSpace(name))
@@ -20,9 +14,9 @@ public sealed class GameCampaignService
if (ruleset is null)
return ServiceResult<CampaignSummary>.Failure("invalid_ruleset", "Unknown ruleset.");
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<CampaignSummary>.Failure("unauthorized", "You must be logged in.");
@@ -35,21 +29,21 @@ public sealed class GameCampaignService
Version = 1
};
m_StateStore.CampaignsById[campaign.Id] = campaign;
m_PersistenceService.PersistStateLocked();
return ServiceResult<CampaignSummary>.Success(GameDtoMapper.ToCampaignSummary(m_StateStore, campaign));
stateStore.CampaignsById[campaign.Id] = campaign;
persistenceService.PersistStateLocked();
return ServiceResult<CampaignSummary>.Success(GameDtoMapper.ToCampaignSummary(stateStore, campaign));
}
}
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unauthorized", "You must be logged in.");
var results = m_StateStore.CampaignsById.Values.Where(campaign => GameAuthorization.CanViewCampaign(m_StateStore, user.Id, campaign.Id)).OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(campaign => GameDtoMapper.ToCampaignSummary(m_StateStore, campaign)).ToArray();
var results = stateStore.CampaignsById.Values.Where(campaign => GameAuthorization.CanViewCampaign(stateStore, user.Id, campaign.Id)).OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(campaign => GameDtoMapper.ToCampaignSummary(stateStore, campaign)).ToArray();
return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results);
}
@@ -57,13 +51,13 @@ public sealed class GameCampaignService
public ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<CampaignOption>>.Failure("unauthorized", "You must be logged in.");
var options = m_StateStore.CampaignsById.Values.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(GameDtoMapper.ToCampaignOption).ToArray();
var options = stateStore.CampaignsById.Values.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).Select(GameDtoMapper.ToCampaignOption).ToArray();
return ServiceResult<IReadOnlyList<CampaignOption>>.Success(options);
}
@@ -71,50 +65,47 @@ public sealed class GameCampaignService
public ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var context = GameContextResolver.ResolveCampaignContextLocked(m_StateStore, sessionToken, campaignId);
var context = GameContextResolver.ResolveCampaignContextLocked(stateStore, sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<CampaignRoster>.Failure(context.Error!.Code, context.Error.Message);
var (_, campaign) = context.Value;
return ServiceResult<CampaignRoster>.Success(GameDtoMapper.ToCampaignRoster(m_StateStore, campaign));
return ServiceResult<CampaignRoster>.Success(GameDtoMapper.ToCampaignRoster(stateStore, campaign));
}
}
public ServiceResult<bool> DeleteCampaign(string sessionToken, Guid campaignId)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.CampaignsById.TryGetValue(campaignId, out var campaign))
if (!stateStore.CampaignsById.TryGetValue(campaignId, out var campaign))
return ServiceResult<bool>.Failure("campaign_not_found", "Campaign was not found.");
if (campaign.GmUserId != user.Id && !GameAuthorization.HasRole(user, UserRoles.Admin))
return ServiceResult<bool>.Failure("forbidden", "Only the campaign owner or admin can delete this campaign.");
DeleteCampaignLocked(campaignId);
m_PersistenceService.PersistStateLocked();
persistenceService.PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
}
private void DeleteCampaignLocked(Guid campaignId)
{
if (!m_StateStore.CampaignsById.Remove(campaignId))
if (!stateStore.CampaignsById.Remove(campaignId))
return;
var affectedCharacterIds = m_StateStore.CharactersById.Values.Where(character => character.CampaignId == campaignId).Select(character => character.Id).ToArray();
var affectedCharacterIds = stateStore.CharactersById.Values.Where(character => character.CampaignId == campaignId).Select(character => character.Id).ToArray();
foreach (var characterId in affectedCharacterIds)
m_StateStore.CharactersById[characterId].CampaignId = null;
stateStore.CharactersById[characterId].CampaignId = null;
m_StateStore.RollLog.RemoveAll(entry => entry.CampaignId == campaignId);
m_StateStore.CampaignStateById.Remove(campaignId);
stateStore.RollLog.RemoveAll(entry => entry.CampaignId == campaignId);
stateStore.CampaignStateById.Remove(campaignId);
}
private readonly GamePersistenceService m_PersistenceService;
private readonly GameStateStore m_StateStore;
}

View File

@@ -3,26 +3,20 @@ using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class GameCharacterService
public sealed class GameCharacterService(GameStateStore stateStore, GamePersistenceService persistenceService)
{
public GameCharacterService(GameStateStore stateStore, GamePersistenceService persistenceService)
{
m_StateStore = stateStore;
m_PersistenceService = persistenceService;
}
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_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.CampaignsById.ContainsKey(campaignId))
if (!stateStore.CampaignsById.ContainsKey(campaignId))
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
var character = new Character
@@ -33,12 +27,12 @@ public sealed class GameCharacterService
Name = name.Trim()
};
m_StateStore.CharactersById[character.Id] = character;
m_StateStore.AddCharacterStateLocked(character.CampaignId, character.Id);
m_StateStore.TouchRosterLocked(character.CampaignId);
stateStore.CharactersById[character.Id] = character;
stateStore.AddCharacterStateLocked(character.CampaignId, character.Id);
stateStore.TouchRosterLocked(character.CampaignId);
m_PersistenceService.PersistStateLocked();
return ServiceResult<CharacterSummary>.Success(GameDtoMapper.ToCharacterSummary(m_StateStore, character));
persistenceService.PersistStateLocked();
return ServiceResult<CharacterSummary>.Success(GameDtoMapper.ToCharacterSummary(stateStore, character));
}
}
@@ -47,22 +41,22 @@ public sealed class GameCharacterService
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required.");
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character))
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
return ServiceResult<CharacterSummary>.Failure("character_not_found", "Character was not found.");
Campaign? targetCampaign = null;
if (campaignId.HasValue && !m_StateStore.CampaignsById.TryGetValue(campaignId.Value, out targetCampaign))
if (campaignId.HasValue && !stateStore.CampaignsById.TryGetValue(campaignId.Value, out targetCampaign))
return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
var isOwner = character.OwnerUserId == user.Id;
var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin);
var isSourceGm = character.CampaignId.HasValue && m_StateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) && sourceCampaign.GmUserId == user.Id;
var isSourceGm = character.CampaignId.HasValue && stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var sourceCampaign) && sourceCampaign.GmUserId == user.Id;
var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id;
if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm)
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner, GM, or admin can edit this character.");
@@ -76,41 +70,41 @@ public sealed class GameCharacterService
{
var trimmedOwnerUsername = ownerUsername.Trim();
var normalizedOwnerUsername = NormalizeUsername(trimmedOwnerUsername);
if (!m_StateStore.UserIdsByUsername.TryGetValue(normalizedOwnerUsername, out var targetOwnerUserId))
if (!stateStore.UserIdsByUsername.TryGetValue(normalizedOwnerUsername, out var targetOwnerUserId))
return ServiceResult<CharacterSummary>.Failure("owner_not_found", "Owner username was not found.");
if (targetOwnerUserId != character.OwnerUserId && !isAdmin && !isSourceGm && !isTargetGm)
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM or admin can change character owner.");
character.OwnerUserId = targetOwnerUserId;
if (character.OwnerUserId != previousOwnerUserId && m_StateStore.UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) && previousOwner.ActiveCharacterId == character.Id)
if (character.OwnerUserId != previousOwnerUserId && stateStore.UsersById.TryGetValue(previousOwnerUserId, out var previousOwner) && previousOwner.ActiveCharacterId == character.Id)
previousOwner.ActiveCharacterId = null;
}
if (sourceCampaignId != character.CampaignId)
{
m_StateStore.RemoveCharacterStateLocked(sourceCampaignId, character.Id);
m_StateStore.AddCharacterStateLocked(character.CampaignId, character.Id);
stateStore.RemoveCharacterStateLocked(sourceCampaignId, character.Id);
stateStore.AddCharacterStateLocked(character.CampaignId, character.Id);
}
m_StateStore.TouchRosterLocked(sourceCampaignId);
stateStore.TouchRosterLocked(sourceCampaignId);
if (sourceCampaignId != character.CampaignId)
m_StateStore.TouchRosterLocked(character.CampaignId);
stateStore.TouchRosterLocked(character.CampaignId);
m_PersistenceService.PersistStateLocked();
return ServiceResult<CharacterSummary>.Success(GameDtoMapper.ToCharacterSummary(m_StateStore, character));
persistenceService.PersistStateLocked();
return ServiceResult<CharacterSummary>.Success(GameDtoMapper.ToCharacterSummary(stateStore, character));
}
}
public ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character))
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
var isOwner = character.OwnerUserId == user.Id;
@@ -119,40 +113,40 @@ public sealed class GameCharacterService
return ServiceResult<bool>.Failure("forbidden", "Only the owner or admin can delete this character.");
DeleteCharacterLocked(characterId);
m_PersistenceService.PersistStateLocked();
persistenceService.PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
}
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character))
if (!stateStore.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;
m_PersistenceService.PersistStateLocked();
persistenceService.PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
}
public ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in.");
var characters = m_StateStore.CharactersById.Values.Where(character => character.OwnerUserId == user.Id).OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase).Select(character => GameDtoMapper.ToCharacterSummary(m_StateStore, character)).ToArray();
var characters = stateStore.CharactersById.Values.Where(character => character.OwnerUserId == user.Id).OrderBy(character => character.Name, StringComparer.OrdinalIgnoreCase).Select(character => GameDtoMapper.ToCharacterSummary(stateStore, character)).ToArray();
return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
}
@@ -160,34 +154,31 @@ public sealed class GameCharacterService
private void DeleteCharacterLocked(Guid characterId)
{
if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character))
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
return;
var campaignId = character.CampaignId;
m_StateStore.CharactersById.Remove(characterId);
stateStore.CharactersById.Remove(characterId);
var skillGroupIds = m_StateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet();
var skillGroupIds = stateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet();
foreach (var skillGroupId in skillGroupIds)
m_StateStore.SkillGroupsById.Remove(skillGroupId);
stateStore.SkillGroupsById.Remove(skillGroupId);
var skillIds = m_StateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet();
var skillIds = stateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet();
foreach (var skillId in skillIds)
m_StateStore.SkillsById.Remove(skillId);
stateStore.SkillsById.Remove(skillId);
m_StateStore.RollLog.RemoveAll(entry => entry.CharacterId == characterId || skillIds.Contains(entry.SkillId));
stateStore.RollLog.RemoveAll(entry => entry.CharacterId == characterId || skillIds.Contains(entry.SkillId));
foreach (var user in m_StateStore.UsersById.Values.Where(account => account.ActiveCharacterId == characterId))
foreach (var user in stateStore.UsersById.Values.Where(account => account.ActiveCharacterId == characterId))
user.ActiveCharacterId = null;
m_StateStore.RemoveCharacterStateLocked(campaignId, characterId);
m_StateStore.TouchRosterLocked(campaignId);
stateStore.RemoveCharacterStateLocked(campaignId, characterId);
stateStore.TouchRosterLocked(campaignId);
}
private static string NormalizeUsername(string username)
{
return username.ToUpperInvariant();
}
private readonly GamePersistenceService m_PersistenceService;
private readonly GameStateStore m_StateStore;
}

View File

@@ -32,8 +32,8 @@ public static class GameContextResolver
public static bool TryResolveCharacterCampaignLocked(GameStateStore stateStore, Character character, out Campaign campaign, out ServiceError? error)
{
campaign = default!;
if (!character.CampaignId.HasValue || !stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign) || resolvedCampaign is null)
campaign = null!;
if (!character.CampaignId.HasValue || !stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign))
{
error = new("character_not_in_campaign", "Character is not linked to a campaign.");
return false;

View File

@@ -4,17 +4,11 @@ using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class GamePersistenceService
public sealed class GamePersistenceService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, GameStateStore stateStore)
{
public GamePersistenceService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, GameStateStore stateStore)
{
m_DbContextFactory = dbContextFactory;
m_StateStore = stateStore;
}
public void LoadStateFromDatabase()
{
using var db = m_DbContextFactory.CreateDbContext();
using var db = dbContextFactory.CreateDbContext();
var users = db.Users.AsNoTracking().ToList();
var sessions = db.Sessions.AsNoTracking().ToList();
var campaigns = db.Campaigns.AsNoTracking().ToList();
@@ -23,16 +17,16 @@ public sealed class GamePersistenceService
var skills = db.Skills.AsNoTracking().ToList();
var logEntries = db.RollLogEntries.AsNoTracking().ToList().OrderBy(x => x.TimestampUtc).ThenBy(x => x.Id).ToList();
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
m_StateStore.UsersById.Clear();
m_StateStore.UserIdsByUsername.Clear();
m_StateStore.SessionsByToken.Clear();
m_StateStore.CampaignsById.Clear();
m_StateStore.CharactersById.Clear();
m_StateStore.SkillGroupsById.Clear();
m_StateStore.SkillsById.Clear();
m_StateStore.RollLog.Clear();
stateStore.UsersById.Clear();
stateStore.UserIdsByUsername.Clear();
stateStore.SessionsByToken.Clear();
stateStore.CampaignsById.Clear();
stateStore.CharactersById.Clear();
stateStore.SkillGroupsById.Clear();
stateStore.SkillsById.Clear();
stateStore.RollLog.Clear();
foreach (var user in users)
{
@@ -48,35 +42,35 @@ public sealed class GamePersistenceService
Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)),
ActiveCharacterId = user.ActiveCharacterId
};
m_StateStore.UsersById[storedUser.Id] = storedUser;
m_StateStore.UserIdsByUsername[normalizedUsername] = storedUser.Id;
stateStore.UsersById[storedUser.Id] = storedUser;
stateStore.UserIdsByUsername[normalizedUsername] = storedUser.Id;
}
foreach (var session in sessions)
{
if (m_StateStore.UsersById.ContainsKey(session.UserId))
m_StateStore.SessionsByToken[session.Token] = GameStateCloneFactory.CloneSession(session);
if (stateStore.UsersById.ContainsKey(session.UserId))
stateStore.SessionsByToken[session.Token] = GameStateCloneFactory.CloneSession(session);
}
foreach (var campaign in campaigns)
m_StateStore.CampaignsById[campaign.Id] = GameStateCloneFactory.CloneCampaign(campaign);
stateStore.CampaignsById[campaign.Id] = GameStateCloneFactory.CloneCampaign(campaign);
foreach (var character in characters)
m_StateStore.CharactersById[character.Id] = GameStateCloneFactory.CloneCharacter(character);
stateStore.CharactersById[character.Id] = GameStateCloneFactory.CloneCharacter(character);
foreach (var skillGroup in skillGroups)
m_StateStore.SkillGroupsById[skillGroup.Id] = GameStateCloneFactory.CloneSkillGroup(skillGroup);
stateStore.SkillGroupsById[skillGroup.Id] = GameStateCloneFactory.CloneSkillGroup(skillGroup);
foreach (var skill in skills)
m_StateStore.SkillsById[skill.Id] = GameStateCloneFactory.CloneSkill(skill);
stateStore.SkillsById[skill.Id] = GameStateCloneFactory.CloneSkill(skill);
m_StateStore.RollLog.AddRange(logEntries.Select(GameStateCloneFactory.CloneRollLogEntry));
stateStore.RollLog.AddRange(logEntries.Select(GameStateCloneFactory.CloneRollLogEntry));
}
}
public void PersistStateLocked()
{
using var db = m_DbContextFactory.CreateDbContext();
using var db = dbContextFactory.CreateDbContext();
using var transaction = db.Database.BeginTransaction();
db.RollLogEntries.ExecuteDelete();
@@ -87,13 +81,16 @@ public sealed class GamePersistenceService
db.Sessions.ExecuteDelete();
db.Users.ExecuteDelete();
db.Users.AddRange(m_StateStore.UsersById.Values.Select(GameStateCloneFactory.CloneUser));
db.Sessions.AddRange(m_StateStore.SessionsByToken.Values.Select(GameStateCloneFactory.CloneSession));
db.Campaigns.AddRange(m_StateStore.CampaignsById.Values.Select(GameStateCloneFactory.CloneCampaign));
db.Characters.AddRange(m_StateStore.CharactersById.Values.Select(GameStateCloneFactory.CloneCharacter));
db.SkillGroups.AddRange(m_StateStore.SkillGroupsById.Values.Select(GameStateCloneFactory.CloneSkillGroup));
db.Skills.AddRange(m_StateStore.SkillsById.Values.Select(GameStateCloneFactory.CloneSkill));
db.RollLogEntries.AddRange(m_StateStore.RollLog.Select(GameStateCloneFactory.CloneRollLogEntry));
lock (stateStore.Gate)
{
db.Users.AddRange(stateStore.UsersById.Values.Select(GameStateCloneFactory.CloneUser));
db.Sessions.AddRange(stateStore.SessionsByToken.Values.Select(GameStateCloneFactory.CloneSession));
db.Campaigns.AddRange(stateStore.CampaignsById.Values.Select(GameStateCloneFactory.CloneCampaign));
db.Characters.AddRange(stateStore.CharactersById.Values.Select(GameStateCloneFactory.CloneCharacter));
db.SkillGroups.AddRange(stateStore.SkillGroupsById.Values.Select(GameStateCloneFactory.CloneSkillGroup));
db.Skills.AddRange(stateStore.SkillsById.Values.Select(GameStateCloneFactory.CloneSkill));
db.RollLogEntries.AddRange(stateStore.RollLog.Select(GameStateCloneFactory.CloneRollLogEntry));
}
db.SaveChanges();
transaction.Commit();
@@ -103,7 +100,4 @@ public sealed class GamePersistenceService
{
return username.ToUpperInvariant();
}
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
private readonly GameStateStore m_StateStore;
}

View File

@@ -4,29 +4,21 @@ using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class GameRollService
public sealed class GameRollService(GameStateStore stateStore, GamePersistenceService persistenceService, IDiceRoller diceRoller)
{
public GameRollService(GameStateStore stateStore, GamePersistenceService persistenceService, IDiceRoller diceRoller)
{
m_StateStore = stateStore;
m_PersistenceService = persistenceService;
m_DiceRoller = diceRoller;
m_RollEngine = new(new(diceRoller), new(diceRoller), new(diceRoller));
}
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.SkillsById.TryGetValue(skillId, out var skill))
if (!stateStore.SkillsById.TryGetValue(skillId, out var skill))
return ServiceResult<RollResult>.Failure("skill_not_found", "Skill was not found.");
var character = m_StateStore.CharactersById[skill.CharacterId];
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
var character = stateStore.CharactersById[skill.CharacterId];
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
return ServiceResult<RollResult>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
@@ -47,16 +39,16 @@ public sealed class GameRollService
public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character))
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
return ServiceResult<RollResult>.Failure("character_not_found", "Character was not found.");
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
return ServiceResult<RollResult>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
@@ -78,13 +70,13 @@ public sealed class GameRollService
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var context = GameContextResolver.ResolveCampaignContextLocked(m_StateStore, sessionToken, campaignId);
var context = GameContextResolver.ResolveCampaignContextLocked(stateStore, sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Failure(context.Error!.Code, context.Error.Message);
var (user, campaign) = context.Value!;
var (user, campaign) = context.Value;
var entries = GetVisibleCampaignLogEntriesLocked(user, campaign).TakeLast(CampaignLogHistoryWindowSize).Select(ToLogEntry).ToArray();
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries);
@@ -93,13 +85,13 @@ public sealed class GameRollService
public ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var context = GameContextResolver.ResolveCampaignContextLocked(m_StateStore, sessionToken, campaignId);
var context = GameContextResolver.ResolveCampaignContextLocked(stateStore, sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<CampaignLogPage>.Failure(context.Error!.Code, context.Error.Message);
var (user, campaign) = context.Value!;
var (user, campaign) = context.Value;
var pageSize = NormalizeCampaignLogPageSize(limit);
var visibleEntries = GetVisibleCampaignLogEntriesLocked(user, campaign).ToArray();
@@ -133,17 +125,17 @@ public sealed class GameRollService
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<CampaignRollDetail>.Failure("unauthorized", "You must be logged in.");
var entry = m_StateStore.RollLog.FirstOrDefault(candidate => candidate.Id == rollId);
var entry = stateStore.RollLog.FirstOrDefault(candidate => candidate.Id == rollId);
if (entry is null)
return ServiceResult<CampaignRollDetail>.Failure("roll_not_found", "Roll was not found.");
if (!m_StateStore.CampaignsById.TryGetValue(entry.CampaignId, out var campaign) || !GameAuthorization.CanViewRoll(m_StateStore, user.Id, campaign, entry))
if (!stateStore.CampaignsById.TryGetValue(entry.CampaignId, out var campaign) || !GameAuthorization.CanViewRoll(stateStore, user.Id, campaign, entry))
return ServiceResult<CampaignRollDetail>.Failure("roll_not_found", "Roll was not found.");
return ServiceResult<CampaignRollDetail>.Success(GameDtoMapper.ToCampaignRollDetail(entry, DeserializeDice(entry.Dice).ToArray()));
@@ -152,13 +144,13 @@ public sealed class GameRollService
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var context = GameContextResolver.ResolveCampaignContextLocked(m_StateStore, sessionToken, campaignId);
var context = GameContextResolver.ResolveCampaignContextLocked(stateStore, sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<CampaignStateSnapshot>.Failure(context.Error!.Code, context.Error.Message);
return ServiceResult<CampaignStateSnapshot>.Success(GameDtoMapper.ToCampaignStateSnapshot(m_StateStore, context.Value!.Campaign.Id));
return ServiceResult<CampaignStateSnapshot>.Success(GameDtoMapper.ToCampaignStateSnapshot(stateStore, context.Value.Campaign.Id));
}
}
@@ -178,10 +170,10 @@ public sealed class GameRollService
TimestampUtc = DateTimeOffset.UtcNow
};
m_StateStore.RollLog.Add(entry);
m_StateStore.TouchLogLocked(campaign.Id);
stateStore.RollLog.Add(entry);
stateStore.TouchLogLocked(campaign.Id);
m_PersistenceService.PersistStateLocked();
persistenceService.PersistStateLocked();
return ServiceResult<RollResult>.Success(GameDtoMapper.ToRollResult(entry, roll.Dice));
}
@@ -192,15 +184,15 @@ public sealed class GameRollService
private IEnumerable<RollLogEntry> GetVisibleCampaignLogEntriesLocked(UserAccount user, Campaign campaign)
{
return m_StateStore.RollLog.Where(r => r.CampaignId == campaign.Id).Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id).OrderBy(r => r.TimestampUtc).ThenBy(r => r.Id);
return stateStore.RollLog.Where(r => r.CampaignId == campaign.Id).Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id).OrderBy(r => r.TimestampUtc).ThenBy(r => r.Id);
}
private CampaignLogEntry ToLogEntry(RollLogEntry entry)
{
var dice = DeserializeDice(entry.Dice);
var characterName = m_StateStore.CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
var characterName = stateStore.CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
var skillName = ResolveLoggedSkillName(entry);
var rollerDisplayName = GameDtoMapper.ResolveOwnerDisplayName(m_StateStore, entry.RollerUserId, "Unknown owner");
var rollerDisplayName = GameDtoMapper.ResolveOwnerDisplayName(stateStore, entry.RollerUserId, "Unknown owner");
return GameDtoMapper.ToCampaignLogEntry(entry, characterName, skillName, rollerDisplayName, dice);
}
@@ -208,7 +200,7 @@ public sealed class GameRollService
private CampaignLogListEntry ToLogListEntry(UserAccount user, Campaign campaign, RollLogEntry entry)
{
var dice = DeserializeDice(entry.Dice);
var characterName = m_StateStore.CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
var characterName = stateStore.CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
var skillName = ResolveLoggedSkillName(entry);
var loggedExpression = ResolveLoggedExpression(entry);
var eventBadges = CampaignLogSummaryBuilder.BuildCompactLogEventBadges(campaign.Ruleset, loggedExpression, dice);
@@ -226,7 +218,7 @@ public sealed class GameRollService
if (entry.SkillId == CustomRollSkillId)
return CustomRollLabel;
return m_StateStore.SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.Name : "Unknown skill";
return stateStore.SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.Name : "Unknown skill";
}
private string? ResolveLoggedExpression(RollLogEntry entry)
@@ -234,7 +226,7 @@ public sealed class GameRollService
if (entry.SkillId == CustomRollSkillId)
return CampaignLogSummaryBuilder.ExtractCustomRollExpression(entry.Breakdown, CustomRollBreakdownSeparator);
return m_StateStore.SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.DiceRollDefinition : null;
return stateStore.SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.DiceRollDefinition : null;
}
private string ResolveLogRollerLabel(UserAccount user, Campaign campaign, RollLogEntry entry)
@@ -245,7 +237,7 @@ public sealed class GameRollService
if (entry.RollerUserId == campaign.GmUserId)
return "GM";
return GameDtoMapper.ResolveOwnerDisplayName(m_StateStore, entry.RollerUserId, "Unknown owner");
return GameDtoMapper.ResolveOwnerDisplayName(stateStore, entry.RollerUserId, "Unknown owner");
}
private static string ResolveLogVisibilityLabel(UserAccount user, Campaign campaign, RollLogEntry entry)
@@ -299,8 +291,6 @@ public sealed class GameRollService
private const string CustomRollLabel = "Custom roll";
private static readonly Guid CustomRollSkillId = Guid.Empty;
private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions();
private readonly IDiceRoller m_DiceRoller;
private readonly GamePersistenceService m_PersistenceService;
private readonly RollEngine m_RollEngine;
private readonly GameStateStore m_StateStore;
private readonly IDiceRoller m_DiceRoller = diceRoller;
private readonly RollEngine m_RollEngine = new(new(diceRoller), new(diceRoller), new(diceRoller));
}

View File

@@ -10,18 +10,18 @@ public sealed class GameService : IGameService
{
public GameService(IDbContextFactory<RpgRollerDbContext> dbContextFactory, IPasswordHasher<UserAccount> passwordHasher, IDiceRoller diceRoller)
{
m_StateStore = new();
m_PersistenceService = new(dbContextFactory, m_StateStore);
m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService);
m_CampaignService = new(m_StateStore, m_PersistenceService);
m_CharacterService = new(m_StateStore, m_PersistenceService);
m_RollService = new(m_StateStore, m_PersistenceService, diceRoller);
m_SkillService = new(m_StateStore, m_PersistenceService);
m_UserAdministrationService = new(m_StateStore, m_PersistenceService);
m_PersistenceService.LoadStateFromDatabase();
lock (m_StateStore.Gate)
GameStateStore stateStore = new();
GamePersistenceService persistenceService = new(dbContextFactory, stateStore);
m_AuthService = new(stateStore, passwordHasher, persistenceService);
m_CampaignService = new(stateStore, persistenceService);
m_CharacterService = new(stateStore, persistenceService);
m_RollService = new(stateStore, persistenceService, diceRoller);
m_SkillService = new(stateStore, persistenceService);
m_UserAdministrationService = new(stateStore, persistenceService);
persistenceService.LoadStateFromDatabase();
lock (stateStore.Gate)
{
m_StateStore.RebuildCampaignStateLocked();
stateStore.RebuildCampaignStateLocked();
}
}
@@ -191,12 +191,9 @@ public sealed class GameService : IGameService
}
private readonly GameAuthService m_AuthService;
private readonly GameCampaignService m_CampaignService;
private readonly GameCharacterService m_CharacterService;
private readonly GamePersistenceService m_PersistenceService;
private readonly GameRollService m_RollService;
private readonly GameSkillService m_SkillService;
private readonly GameStateStore m_StateStore;
private readonly GameUserAdministrationService m_UserAdministrationService;
}

View File

@@ -3,29 +3,23 @@ using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class GameSkillService
public sealed class GameSkillService(GameStateStore stateStore, GamePersistenceService persistenceService)
{
public GameSkillService(GameStateStore stateStore, GamePersistenceService persistenceService)
{
m_StateStore = stateStore;
m_PersistenceService = persistenceService;
}
public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
{
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required.");
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<SkillGroupSummary>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character))
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
return ServiceResult<SkillGroupSummary>.Failure("character_not_found", "Character was not found.");
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
return ServiceResult<SkillGroupSummary>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
@@ -40,16 +34,16 @@ public sealed class GameSkillService
Id = Guid.NewGuid(),
CharacterId = character.Id,
Name = name.Trim(),
DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression,
DiceRollDefinition = prototypeValidation.Value.CanonicalExpression,
WildDice = prototypeValidation.Value.WildDice,
AllowFumble = prototypeValidation.Value.AllowFumble,
FumbleRange = prototypeValidation.Value.FumbleRange
};
m_StateStore.SkillGroupsById[group.Id] = group;
m_StateStore.TouchCharacterLocked(campaign.Id, character.Id);
stateStore.SkillGroupsById[group.Id] = group;
stateStore.TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.PersistStateLocked();
persistenceService.PersistStateLocked();
return ServiceResult<SkillGroupSummary>.Success(GameDtoMapper.ToSkillGroupSummary(group));
}
}
@@ -59,17 +53,17 @@ public sealed class GameSkillService
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required.");
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<SkillGroupSummary>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.SkillGroupsById.TryGetValue(skillGroupId, out var group))
if (!stateStore.SkillGroupsById.TryGetValue(skillGroupId, out var group))
return ServiceResult<SkillGroupSummary>.Failure("skill_group_not_found", "Skill group was not found.");
var character = m_StateStore.CharactersById[group.CharacterId];
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
var character = stateStore.CharactersById[group.CharacterId];
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
return ServiceResult<SkillGroupSummary>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
@@ -80,42 +74,42 @@ public sealed class GameSkillService
return ServiceResult<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message);
group.Name = name.Trim();
group.DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression;
group.DiceRollDefinition = prototypeValidation.Value.CanonicalExpression;
group.WildDice = prototypeValidation.Value.WildDice;
group.AllowFumble = prototypeValidation.Value.AllowFumble;
group.FumbleRange = prototypeValidation.Value.FumbleRange;
m_StateStore.TouchCharacterLocked(campaign.Id, character.Id);
stateStore.TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.PersistStateLocked();
persistenceService.PersistStateLocked();
return ServiceResult<SkillGroupSummary>.Success(GameDtoMapper.ToSkillGroupSummary(group));
}
}
public ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.SkillGroupsById.TryGetValue(skillGroupId, out var group))
if (!stateStore.SkillGroupsById.TryGetValue(skillGroupId, out var group))
return ServiceResult<bool>.Failure("skill_group_not_found", "Skill group was not found.");
var character = m_StateStore.CharactersById[group.CharacterId];
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
var character = stateStore.CharactersById[group.CharacterId];
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
return ServiceResult<bool>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
return ServiceResult<bool>.Failure("forbidden", "Only the owner or GM can manage skill groups.");
foreach (var skill in m_StateStore.SkillsById.Values.Where(skill => skill.SkillGroupId == group.Id))
foreach (var skill in stateStore.SkillsById.Values.Where(skill => skill.SkillGroupId == group.Id))
skill.SkillGroupId = null;
m_StateStore.SkillGroupsById.Remove(group.Id);
m_StateStore.TouchCharacterLocked(campaign.Id, character.Id);
stateStore.SkillGroupsById.Remove(group.Id);
stateStore.TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.PersistStateLocked();
persistenceService.PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
}
@@ -125,16 +119,16 @@ public sealed class GameSkillService
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character))
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
return ServiceResult<SkillSummary>.Failure("character_not_found", "Character was not found.");
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
return ServiceResult<SkillSummary>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
@@ -154,16 +148,16 @@ public sealed class GameSkillService
CharacterId = character.Id,
SkillGroupId = resolvedSkillGroupId.Value,
Name = name.Trim(),
DiceRollDefinition = skillValidation.Value!.CanonicalExpression,
DiceRollDefinition = skillValidation.Value.CanonicalExpression,
WildDice = skillValidation.Value.WildDice,
AllowFumble = skillValidation.Value.AllowFumble,
FumbleRange = skillValidation.Value.FumbleRange
};
m_StateStore.SkillsById[skill.Id] = skill;
m_StateStore.TouchCharacterLocked(campaign.Id, character.Id);
stateStore.SkillsById[skill.Id] = skill;
stateStore.TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.PersistStateLocked();
persistenceService.PersistStateLocked();
return ServiceResult<SkillSummary>.Success(GameDtoMapper.ToSkillSummary(skill));
}
}
@@ -173,17 +167,17 @@ public sealed class GameSkillService
if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required.");
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.SkillsById.TryGetValue(skillId, out var skill))
if (!stateStore.SkillsById.TryGetValue(skillId, out var skill))
return ServiceResult<SkillSummary>.Failure("skill_not_found", "Skill was not found.");
var character = m_StateStore.CharactersById[skill.CharacterId];
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
var character = stateStore.CharactersById[skill.CharacterId];
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
return ServiceResult<SkillSummary>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
@@ -198,62 +192,62 @@ public sealed class GameSkillService
return ServiceResult<SkillSummary>.Failure(resolvedSkillGroupId.Error!.Code, resolvedSkillGroupId.Error.Message);
skill.Name = name.Trim();
skill.DiceRollDefinition = skillValidation.Value!.CanonicalExpression;
skill.DiceRollDefinition = skillValidation.Value.CanonicalExpression;
skill.WildDice = skillValidation.Value.WildDice;
skill.AllowFumble = skillValidation.Value.AllowFumble;
skill.FumbleRange = skillValidation.Value.FumbleRange;
skill.SkillGroupId = resolvedSkillGroupId.Value;
m_StateStore.TouchCharacterLocked(campaign.Id, character.Id);
stateStore.TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.PersistStateLocked();
persistenceService.PersistStateLocked();
return ServiceResult<SkillSummary>.Success(GameDtoMapper.ToSkillSummary(skill));
}
}
public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.SkillsById.TryGetValue(skillId, out var skill))
if (!stateStore.SkillsById.TryGetValue(skillId, out var skill))
return ServiceResult<bool>.Failure("skill_not_found", "Skill was not found.");
var character = m_StateStore.CharactersById[skill.CharacterId];
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
var character = stateStore.CharactersById[skill.CharacterId];
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
return ServiceResult<bool>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
return ServiceResult<bool>.Failure("forbidden", "Only the owner or GM can edit skills.");
m_StateStore.SkillsById.Remove(skill.Id);
m_StateStore.TouchCharacterLocked(campaign.Id, character.Id);
stateStore.SkillsById.Remove(skill.Id);
stateStore.TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.PersistStateLocked();
persistenceService.PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
}
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<CharacterSheet>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character))
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
return ServiceResult<CharacterSheet>.Failure("character_not_found", "Character was not found.");
if (!GameContextResolver.TryResolveCharacterCampaignLocked(m_StateStore, character, out var campaign, out var campaignError))
if (!GameContextResolver.TryResolveCharacterCampaignLocked(stateStore, character, out var campaign, out var campaignError))
return ServiceResult<CharacterSheet>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanViewCampaign(m_StateStore, user.Id, campaign.Id))
if (!GameAuthorization.CanViewCampaign(stateStore, user.Id, campaign.Id))
return ServiceResult<CharacterSheet>.Failure("forbidden", "You are not a participant in this campaign.");
return ServiceResult<CharacterSheet>.Success(GameDtoMapper.ToCharacterSheet(m_StateStore, character.Id));
return ServiceResult<CharacterSheet>.Success(GameDtoMapper.ToCharacterSheet(stateStore, character.Id));
}
}
@@ -262,7 +256,7 @@ public sealed class GameSkillService
if (!requestedSkillGroupId.HasValue)
return ServiceResult<Guid?>.Success(null);
if (!m_StateStore.SkillGroupsById.TryGetValue(requestedSkillGroupId.Value, out var skillGroup))
if (!stateStore.SkillGroupsById.TryGetValue(requestedSkillGroupId.Value, out var skillGroup))
return ServiceResult<Guid?>.Failure("skill_group_not_found", "Skill group was not found.");
if (skillGroup.CharacterId != characterId)
@@ -270,7 +264,4 @@ public sealed class GameSkillService
return ServiceResult<Guid?>.Success(skillGroup.Id);
}
private readonly GamePersistenceService m_PersistenceService;
private readonly GameStateStore m_StateStore;
}

View File

@@ -3,23 +3,17 @@ using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class GameUserAdministrationService
public sealed class GameUserAdministrationService(GameStateStore stateStore, GamePersistenceService persistenceService)
{
public GameUserAdministrationService(GameStateStore stateStore, GamePersistenceService persistenceService)
{
m_StateStore = stateStore;
m_PersistenceService = persistenceService;
}
public ServiceResult<IReadOnlyList<string>> GetUsernames(string sessionToken)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<string>>.Failure("unauthorized", "You must be logged in.");
var usernames = m_StateStore.UsersById.Values.Select(account => account.Username).OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToArray();
var usernames = stateStore.UsersById.Values.Select(account => account.Username).OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToArray();
return ServiceResult<IReadOnlyList<string>>.Success(usernames);
}
@@ -27,16 +21,16 @@ public sealed class GameUserAdministrationService
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("unauthorized", "You must be logged in.");
if (!GameAuthorization.HasRole(user, UserRoles.Admin))
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("forbidden", "Admin role is required.");
var users = m_StateStore.UsersById.Values.OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase).Select(GameDtoMapper.ToAdminUserSummary).ToArray();
var users = stateStore.UsersById.Values.OrderBy(account => account.Username, StringComparer.OrdinalIgnoreCase).Select(GameDtoMapper.ToAdminUserSummary).ToArray();
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Success(users);
}
@@ -44,16 +38,16 @@ public sealed class GameUserAdministrationService
public ServiceResult<AdminUserSummary> UpdateUserRoles(string sessionToken, Guid userId, IReadOnlyList<string> roles)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<AdminUserSummary>.Failure("unauthorized", "You must be logged in.");
if (!GameAuthorization.HasRole(user, UserRoles.Admin))
return ServiceResult<AdminUserSummary>.Failure("forbidden", "Admin role is required.");
if (!m_StateStore.UsersById.TryGetValue(userId, out var targetUser))
if (!stateStore.UsersById.TryGetValue(userId, out var targetUser))
return ServiceResult<AdminUserSummary>.Failure("user_not_found", "User was not found.");
var normalizedRoles = RoleSerializer.Normalize(roles);
@@ -64,16 +58,16 @@ public sealed class GameUserAdministrationService
return ServiceResult<AdminUserSummary>.Failure("forbidden", "You cannot remove your own admin role.");
targetUser.Roles = RoleSerializer.Serialize(normalizedRoles);
m_PersistenceService.PersistStateLocked();
persistenceService.PersistStateLocked();
return ServiceResult<AdminUserSummary>.Success(GameDtoMapper.ToAdminUserSummary(targetUser));
}
}
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId)
{
lock (m_StateStore.Gate)
lock (stateStore.Gate)
{
var user = GameContextResolver.ResolveUserLocked(m_StateStore, sessionToken);
var user = GameContextResolver.ResolveUserLocked(stateStore, sessionToken);
if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
@@ -83,72 +77,69 @@ public sealed class GameUserAdministrationService
if (user.Id == userId)
return ServiceResult<bool>.Failure("forbidden", "You cannot delete your own account.");
if (!m_StateStore.UsersById.TryGetValue(userId, out var targetUser))
if (!stateStore.UsersById.TryGetValue(userId, out var targetUser))
return ServiceResult<bool>.Failure("user_not_found", "User was not found.");
var gmCampaignIds = m_StateStore.CampaignsById.Values.Where(campaign => campaign.GmUserId == targetUser.Id).Select(campaign => campaign.Id).ToArray();
var gmCampaignIds = stateStore.CampaignsById.Values.Where(campaign => campaign.GmUserId == targetUser.Id).Select(campaign => campaign.Id).ToArray();
var gmCampaignIdSet = gmCampaignIds.ToHashSet();
var preservedCharacterIds = m_StateStore.CharactersById.Values.Where(character => character.CampaignId.HasValue && gmCampaignIdSet.Contains(character.CampaignId.Value)).Select(character => character.Id).ToHashSet();
var preservedCharacterIds = stateStore.CharactersById.Values.Where(character => character.CampaignId.HasValue && gmCampaignIdSet.Contains(character.CampaignId.Value)).Select(character => character.Id).ToHashSet();
foreach (var campaignId in gmCampaignIds)
DeleteCampaignLocked(campaignId);
var ownedCharacterIds = m_StateStore.CharactersById.Values.Where(character => character.OwnerUserId == targetUser.Id && !preservedCharacterIds.Contains(character.Id)).Select(character => character.Id).ToArray();
var ownedCharacterIds = stateStore.CharactersById.Values.Where(character => character.OwnerUserId == targetUser.Id && !preservedCharacterIds.Contains(character.Id)).Select(character => character.Id).ToArray();
foreach (var characterId in ownedCharacterIds)
DeleteCharacterLocked(characterId);
m_StateStore.RollLog.RemoveAll(entry => entry.RollerUserId == targetUser.Id);
stateStore.RollLog.RemoveAll(entry => entry.RollerUserId == targetUser.Id);
var staleSessions = m_StateStore.SessionsByToken.Values.Where(session => session.UserId == targetUser.Id).Select(session => session.Token).ToArray();
var staleSessions = stateStore.SessionsByToken.Values.Where(session => session.UserId == targetUser.Id).Select(session => session.Token).ToArray();
foreach (var token in staleSessions)
m_StateStore.SessionsByToken.Remove(token);
stateStore.SessionsByToken.Remove(token);
m_StateStore.UsersById.Remove(targetUser.Id);
m_StateStore.UserIdsByUsername.Remove(targetUser.UsernameNormalized);
stateStore.UsersById.Remove(targetUser.Id);
stateStore.UserIdsByUsername.Remove(targetUser.UsernameNormalized);
m_PersistenceService.PersistStateLocked();
persistenceService.PersistStateLocked();
return ServiceResult<bool>.Success(true);
}
}
private void DeleteCampaignLocked(Guid campaignId)
{
if (!m_StateStore.CampaignsById.Remove(campaignId))
if (!stateStore.CampaignsById.Remove(campaignId))
return;
var affectedCharacterIds = m_StateStore.CharactersById.Values.Where(character => character.CampaignId == campaignId).Select(character => character.Id).ToArray();
var affectedCharacterIds = stateStore.CharactersById.Values.Where(character => character.CampaignId == campaignId).Select(character => character.Id).ToArray();
foreach (var characterId in affectedCharacterIds)
m_StateStore.CharactersById[characterId].CampaignId = null;
stateStore.CharactersById[characterId].CampaignId = null;
m_StateStore.RollLog.RemoveAll(entry => entry.CampaignId == campaignId);
m_StateStore.CampaignStateById.Remove(campaignId);
stateStore.RollLog.RemoveAll(entry => entry.CampaignId == campaignId);
stateStore.CampaignStateById.Remove(campaignId);
}
private void DeleteCharacterLocked(Guid characterId)
{
if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character))
if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
return;
var campaignId = character.CampaignId;
m_StateStore.CharactersById.Remove(characterId);
stateStore.CharactersById.Remove(characterId);
var skillGroupIds = m_StateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet();
var skillGroupIds = stateStore.SkillGroupsById.Values.Where(group => group.CharacterId == characterId).Select(group => group.Id).ToHashSet();
foreach (var skillGroupId in skillGroupIds)
m_StateStore.SkillGroupsById.Remove(skillGroupId);
stateStore.SkillGroupsById.Remove(skillGroupId);
var skillIds = m_StateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet();
var skillIds = stateStore.SkillsById.Values.Where(skill => skill.CharacterId == characterId).Select(skill => skill.Id).ToHashSet();
foreach (var skillId in skillIds)
m_StateStore.SkillsById.Remove(skillId);
stateStore.SkillsById.Remove(skillId);
m_StateStore.RollLog.RemoveAll(entry => entry.CharacterId == characterId || skillIds.Contains(entry.SkillId));
stateStore.RollLog.RemoveAll(entry => entry.CharacterId == characterId || skillIds.Contains(entry.SkillId));
foreach (var account in m_StateStore.UsersById.Values.Where(account => account.ActiveCharacterId == characterId))
foreach (var account in stateStore.UsersById.Values.Where(account => account.ActiveCharacterId == characterId))
account.ActiveCharacterId = null;
m_StateStore.RemoveCharacterStateLocked(campaignId, characterId);
m_StateStore.TouchRosterLocked(campaignId);
stateStore.RemoveCharacterStateLocked(campaignId, characterId);
stateStore.TouchRosterLocked(campaignId);
}
private readonly GamePersistenceService m_PersistenceService;
private readonly GameStateStore m_StateStore;
}

View File

@@ -3,13 +3,8 @@ using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class RolemasterRollEngine
public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
{
public RolemasterRollEngine(IDiceRoller diceRoller)
{
m_DiceRoller = diceRoller;
}
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int? fumbleRange)
{
return expression.Kind switch
@@ -26,7 +21,7 @@ public sealed class RolemasterRollEngine
var total = expression.Modifier;
for (var i = 0; i < expression.DiceCount; i += 1)
{
var value = m_DiceRoller.Roll(expression.Sides);
var value = diceRoller.Roll(expression.Sides);
diceValues[i] = value;
dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value);
total += value;
@@ -37,7 +32,7 @@ public sealed class RolemasterRollEngine
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollOpenEnded(DiceExpression expression, int fumbleRange)
{
var initialRoll = m_DiceRoller.Roll(expression.Sides);
var initialRoll = diceRoller.Roll(expression.Sides);
var followUpRolls = new List<int>();
int? initialContribution = initialRoll <= fumbleRange ? null : initialRoll;
var dice = new List<RollDieResult> { CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution) };
@@ -68,7 +63,7 @@ public sealed class RolemasterRollEngine
while (true)
{
var roll = m_DiceRoller.Roll(100);
var roll = diceRoller.Roll(100);
followUpRolls.Add(roll);
dice.Add(CreateRolemasterDie(roll, sequence, subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh, subtract ? -roll : roll));
@@ -84,6 +79,4 @@ public sealed class RolemasterRollEngine
{
return new(roll, false, false, false, false, kind == RollDieKinds.RolemasterOpenEndedHigh, sequence, kind, signedContribution);
}
private readonly IDiceRoller m_DiceRoller;
}

View File

@@ -3,27 +3,16 @@ using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class RollEngine
public sealed class RollEngine(StandardRollEngine standardRollEngine, D6RollEngine d6RollEngine, RolemasterRollEngine rolemasterRollEngine)
{
public RollEngine(StandardRollEngine standardRollEngine, D6RollEngine d6RollEngine, RolemasterRollEngine rolemasterRollEngine)
{
m_StandardRollEngine = standardRollEngine;
m_D6RollEngine = d6RollEngine;
m_RolemasterRollEngine = rolemasterRollEngine;
}
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange)
{
if (ruleset == RulesetKind.D6)
return m_D6RollEngine.Roll(expression, wildDice, allowFumble);
return d6RollEngine.Roll(expression, wildDice, allowFumble);
if (ruleset == RulesetKind.Rolemaster)
return m_RolemasterRollEngine.Roll(expression, fumbleRange);
return rolemasterRollEngine.Roll(expression, fumbleRange);
return m_StandardRollEngine.Roll(expression);
return standardRollEngine.Roll(expression);
}
private readonly D6RollEngine m_D6RollEngine;
private readonly RolemasterRollEngine m_RolemasterRollEngine;
private readonly StandardRollEngine m_StandardRollEngine;
}

View File

@@ -14,7 +14,7 @@ public static class SkillDefinitionValidator
if (!optionsValidation.Succeeded)
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value!.WildDice, optionsValidation.Value.AllowFumble, optionsValidation.Value.FumbleRange));
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value.WildDice, optionsValidation.Value.AllowFumble, optionsValidation.Value.FumbleRange));
}
private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)> ValidateOptions(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange)

View File

@@ -3,13 +3,8 @@ using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class StandardRollEngine
public sealed class StandardRollEngine(IDiceRoller diceRoller)
{
public StandardRollEngine(IDiceRoller diceRoller)
{
m_DiceRoller = diceRoller;
}
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression)
{
var diceValues = new int[expression.DiceCount];
@@ -17,7 +12,7 @@ public sealed class StandardRollEngine
var total = expression.Modifier;
for (var i = 0; i < expression.DiceCount; i += 1)
{
var value = m_DiceRoller.Roll(expression.Sides);
var value = diceRoller.Roll(expression.Sides);
diceValues[i] = value;
dice[i] = new(value, false, false, false, false, false);
total += value;
@@ -25,6 +20,4 @@ public sealed class StandardRollEngine
return (total, RollBreakdownFormatter.BuildBreakdown(diceValues, expression.Modifier, total), dice);
}
private readonly IDiceRoller m_DiceRoller;
}