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; 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] [Fact]
public async Task RegisterLoginAndMeFlow_WorksWithDuplicateUsernameGuard() public async Task RegisterLoginAndMeFlow_WorksWithDuplicateUsernameGuard()
{ {

View File

@@ -2,12 +2,8 @@ using System.Text;
namespace RpgRoller.Tests; 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] [Fact]
public async Task CampaignCharacterAndSkillFlow_EnforcesRulesetValidation() public async Task CampaignCharacterAndSkillFlow_EnforcesRulesetValidation()
{ {

View File

@@ -1,11 +1,7 @@
namespace RpgRoller.Tests; 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] [Fact]
public async Task RootPath_ServesBlazorFrontendShell() public async Task RootPath_ServesBlazorFrontendShell()
{ {

View File

@@ -1,11 +1,7 @@
namespace RpgRoller.Tests; 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] [Fact]
public async Task AuthenticatedJsonResponses_EnableGzipCompression() public async Task AuthenticatedJsonResponses_EnableGzipCompression()
{ {

View File

@@ -1,11 +1,7 @@
namespace RpgRoller.Tests; 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] [Fact]
public async Task RolemasterRollEndpoints_ExecuteGenericRolemasterExpressions() public async Task RolemasterRollEndpoints_ExecuteGenericRolemasterExpressions()
{ {

View File

@@ -1,11 +1,7 @@
namespace RpgRoller.Tests; 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] [Fact]
public async Task RollVisibilityAndAuthorization_AreEnforced() public async Task RollVisibilityAndAuthorization_AreEnforced()
{ {

View File

@@ -1,11 +1,7 @@
namespace RpgRoller.Tests; 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] [Fact]
public async Task RulesetAndSseEndpoints_ReturnExpectedResponses() public async Task RulesetAndSseEndpoints_ReturnExpectedResponses()
{ {

View File

@@ -11,11 +11,11 @@ public sealed class BackendCoverageTests
var extensionsType = typeof(Program).Assembly.GetType("RpgRoller.Api.SessionTokenHttpContextExtensions"); var extensionsType = typeof(Program).Assembly.GetType("RpgRoller.Api.SessionTokenHttpContextExtensions");
Assert.NotNull(extensionsType); 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); Assert.NotNull(method);
var context = new DefaultHttpContext(); 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); Assert.IsType<InvalidOperationException>(exception.InnerException);
} }
} }

View File

@@ -2,20 +2,15 @@ namespace RpgRoller.Tests;
public sealed class ServiceRollHelperTests 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) public int Roll(int sides)
{ {
var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1; var next = m_Values.Count > 0 ? m_Values.Dequeue() : 1;
return Math.Clamp(next, 1, sides); return Math.Clamp(next, 1, sides);
} }
private readonly Queue<int> m_Values; private readonly Queue<int> m_Values = new(values);
} }
[Fact] [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 gmTransfer = ServiceTestSupport.GetValue(service.UpdateCharacter(gmSession, character.Id, "Transferred", campaign.Id, "receiver"));
var receiver = service.GetUserBySession(receiverSession); var receiver = service.GetUserBySession(receiverSession);
Assert.NotNull(receiver); Assert.NotNull(receiver);
Assert.Equal(receiver!.Id, gmTransfer.OwnerUserId); Assert.Equal(receiver.Id, gmTransfer.OwnerUserId);
var previousOwnerMe = ServiceTestSupport.GetValue(service.GetMe(ownerSession)); var previousOwnerMe = ServiceTestSupport.GetValue(service.GetMe(ownerSession));
Assert.Null(previousOwnerMe.ActiveCharacterId); Assert.Null(previousOwnerMe.ActiveCharacterId);
@@ -133,7 +133,7 @@ public sealed class ServiceSkillGroupAndOwnershipTests
var adminTwo = service.GetUserBySession(adminTwoSession); var adminTwo = service.GetUserBySession(adminTwoSession);
Assert.NotNull(adminTwo); 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)); var adminUnlink = ServiceTestSupport.GetValue(service.UpdateCharacter(adminTwoSession, character.Id, "Admin Unlink", null));
Assert.Null(adminUnlink.CampaignId); Assert.Null(adminUnlink.CampaignId);

View File

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

View File

@@ -6,114 +6,93 @@ using RpgRoller.Domain;
namespace RpgRoller.Components.Pages; namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage] [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() public async Task EnsureAdminUsersLoadedAsync()
{ {
if (!m_State.IsCurrentUserAdmin || m_State.HasLoadedAdminUsers || m_State.IsAdminDataLoading) if (!state.IsCurrentUserAdmin || state.HasLoadedAdminUsers || state.IsAdminDataLoading)
return; return;
m_State.IsAdminDataLoading = true; state.IsAdminDataLoading = true;
try try
{ {
await ReloadAdminUsersAsync(); await ReloadAdminUsersAsync();
} }
catch (ApiRequestException ex) when (ex.StatusCode == 401) catch (ApiRequestException ex) when (ex.StatusCode == 401)
{ {
m_ClearAuthenticatedState(); clearAuthenticatedState();
await m_StopStateEventsAsync(); await stopStateEventsAsync();
await m_OnLoggedOutAsync("Session expired. Please log in again."); await onLoggedOutAsync("Session expired. Please log in again.");
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
m_Feedback.SetStatus(ex.Message, true); feedback.SetStatus(ex.Message, true);
} }
finally finally
{ {
m_State.IsAdminDataLoading = false; state.IsAdminDataLoading = false;
} }
} }
public async Task ToggleAdminRoleAsync(AdminUserSummary user) 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; return;
m_State.IsMutating = true; state.IsMutating = true;
try try
{ {
IReadOnlyList<string> roles = HasAdminRole(user) ? Array.Empty<string>() : [UserRoles.Admin]; 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(); await ReloadAdminUsersAsync();
m_Feedback.SetStatus("User roles updated.", false); feedback.SetStatus("User roles updated.", false);
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
m_Feedback.SetStatus(ex.Message, true); feedback.SetStatus(ex.Message, true);
} }
finally finally
{ {
m_State.IsMutating = false; state.IsMutating = false;
} }
} }
public async Task DeleteUserAsync(AdminUserSummary user) 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; 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) if (!confirmed)
return; return;
m_State.IsMutating = true; state.IsMutating = true;
try try
{ {
_ = await m_ApiClient.RequestAsync<bool>("DELETE", $"/api/admin/users/{user.Id}"); _ = await apiClient.RequestAsync<bool>("DELETE", $"/api/admin/users/{user.Id}");
await ReloadAdminUsersAsync(); await ReloadAdminUsersAsync();
m_Feedback.SetStatus("User deleted.", false); feedback.SetStatus("User deleted.", false);
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
m_Feedback.SetStatus(ex.Message, true); feedback.SetStatus(ex.Message, true);
} }
finally finally
{ {
m_State.IsMutating = false; state.IsMutating = false;
} }
} }
private async Task ReloadAdminUsersAsync() 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) private static bool HasAdminRole(AdminUserSummary user)
{ {
return user.Roles.Contains(UserRoles.Admin, StringComparer.OrdinalIgnoreCase); 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; namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage] [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) public async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
{ {
if (!Guid.TryParse(args.Value?.ToString(), out var campaignId)) if (!Guid.TryParse(args.Value?.ToString(), out var campaignId))
return; return;
m_State.SelectedCampaignId = campaignId; state.SelectedCampaignId = campaignId;
await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString()); await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString());
await m_RefreshCampaignScopeAsync(); await refreshCampaignScopeAsync();
await m_SyncStateEventsAsync(); await syncStateEventsAsync();
m_State.IsScreenMenuOpen = false; state.IsScreenMenuOpen = false;
} }
public async Task OnCampaignCreatedAsync(Guid campaignId) public async Task OnCampaignCreatedAsync(Guid campaignId)
{ {
await m_ReloadCampaignsAsync(campaignId); await reloadCampaignsAsync(campaignId);
await m_ReloadCharacterCampaignOptionsAsync(); await reloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync(); await refreshCampaignScopeAsync();
await m_SyncStateEventsAsync(); await syncStateEventsAsync();
m_Feedback.SetStatus("Campaign created.", false); feedback.SetStatus("Campaign created.", false);
} }
public void OpenCreateCharacterModal() public void OpenCreateCharacterModal()
{ {
m_State.CreateCharacterInitialModel = new() state.CreateCharacterInitialModel = new()
{ {
Name = string.Empty, 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 OwnerUsername = string.Empty
}; };
m_State.CreateCharacterFormVersion += 1; state.CreateCharacterFormVersion += 1;
m_State.CanEditCharacterOwner = false; state.CanEditCharacterOwner = false;
m_State.ShowCreateCharacterModal = true; state.ShowCreateCharacterModal = true;
} }
public async Task OpenEditCharacterModal(CharacterSummary character) public async Task OpenEditCharacterModal(CharacterSummary character)
{ {
if (m_State.IsCurrentUserGm || m_State.IsCurrentUserAdmin) if (state.IsCurrentUserGm || state.IsCurrentUserAdmin)
await m_LoadKnownUsernamesAsync(); await loadKnownUsernamesAsync();
m_State.EditingCharacterId = character.Id; state.EditingCharacterId = character.Id;
m_State.EditCharacterInitialModel = new() state.EditCharacterInitialModel = new()
{ {
Name = character.Name, Name = character.Name,
CampaignId = character.CampaignId?.ToString() ?? string.Empty, CampaignId = character.CampaignId?.ToString() ?? string.Empty,
OwnerUsername = string.Empty OwnerUsername = string.Empty
}; };
m_State.EditCharacterFormVersion += 1; state.EditCharacterFormVersion += 1;
m_State.CanEditCharacterOwner = m_State.IsCurrentUserGm || m_State.IsCurrentUserAdmin; state.CanEditCharacterOwner = state.IsCurrentUserGm || state.IsCurrentUserAdmin;
m_State.ShowEditCharacterModal = true; state.ShowEditCharacterModal = true;
} }
public void CloseCharacterModals() public void CloseCharacterModals()
{ {
m_State.ShowCreateCharacterModal = false; state.ShowCreateCharacterModal = false;
m_State.ShowEditCharacterModal = false; state.ShowEditCharacterModal = false;
m_State.CanEditCharacterOwner = false; state.CanEditCharacterOwner = false;
m_State.EditingCharacterId = null; state.EditingCharacterId = null;
} }
public async Task OnCharacterCreatedAsync(Guid? campaignId) public async Task OnCharacterCreatedAsync(Guid? campaignId)
{ {
CloseCharacterModals(); CloseCharacterModals();
await m_ReloadCampaignsAsync(campaignId); await reloadCampaignsAsync(campaignId);
await m_ReloadCharacterCampaignOptionsAsync(); await reloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync(); await refreshCampaignScopeAsync();
await m_SyncStateEventsAsync(); await syncStateEventsAsync();
m_Feedback.SetStatus("Character created.", false); feedback.SetStatus("Character created.", false);
} }
public async Task OnCharacterUpdatedAsync(Guid? campaignId) public async Task OnCharacterUpdatedAsync(Guid? campaignId)
{ {
CloseCharacterModals(); CloseCharacterModals();
await m_ReloadCampaignsAsync(campaignId); await reloadCampaignsAsync(campaignId);
await m_ReloadCharacterCampaignOptionsAsync(); await reloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync(); await refreshCampaignScopeAsync();
await m_SyncStateEventsAsync(); await syncStateEventsAsync();
m_Feedback.SetStatus(campaignId.HasValue ? "Character updated." : "Character unlinked from campaign.", false); feedback.SetStatus(campaignId.HasValue ? "Character updated." : "Character unlinked from campaign.", false);
} }
public async Task DeleteSelectedCampaignAsync() 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; 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) if (!confirmed)
return; return;
m_State.IsMutating = true; state.IsMutating = true;
try try
{ {
_ = await m_ApiClient.RequestAsync<bool>("DELETE", $"/api/campaigns/{m_State.SelectedCampaign.Id}"); _ = await apiClient.RequestAsync<bool>("DELETE", $"/api/campaigns/{state.SelectedCampaign.Id}");
await m_ReloadCampaignsAsync(null); await reloadCampaignsAsync(null);
await m_ReloadCharacterCampaignOptionsAsync(); await reloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync(); await refreshCampaignScopeAsync();
await m_SyncStateEventsAsync(); await syncStateEventsAsync();
m_Feedback.SetStatus("Campaign deleted.", false); feedback.SetStatus("Campaign deleted.", false);
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
m_Feedback.SetStatus(ex.Message, true); feedback.SetStatus(ex.Message, true);
} }
finally finally
{ {
m_State.IsMutating = false; state.IsMutating = false;
} }
} }
public async Task DeleteCharacterAsync(CharacterSummary character) public async Task DeleteCharacterAsync(CharacterSummary character)
{ {
if (m_State.IsMutating || !CanDeleteCharacter(character)) if (state.IsMutating || !CanDeleteCharacter(character))
return; 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) if (!confirmed)
return; return;
m_State.IsMutating = true; state.IsMutating = true;
try try
{ {
_ = await m_ApiClient.RequestAsync<bool>("DELETE", $"/api/characters/{character.Id}"); _ = await apiClient.RequestAsync<bool>("DELETE", $"/api/characters/{character.Id}");
await m_ReloadCampaignsAsync(m_State.SelectedCampaignId); await reloadCampaignsAsync(state.SelectedCampaignId);
await m_ReloadCharacterCampaignOptionsAsync(); await reloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync(); await refreshCampaignScopeAsync();
await m_SyncStateEventsAsync(); await syncStateEventsAsync();
m_Feedback.SetStatus("Character deleted.", false); feedback.SetStatus("Character deleted.", false);
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
m_Feedback.SetStatus(ex.Message, true); feedback.SetStatus(ex.Message, true);
} }
finally finally
{ {
m_State.IsMutating = false; state.IsMutating = false;
} }
} }
public bool CanEditCharacter(CharacterSummary character) 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) 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 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; namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage] [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) public async Task ReloadCampaignsAsync(Guid? preferredCampaignId)
{ {
var campaigns = await m_WorkspaceQuery.GetCampaignsAsync(); var campaigns = await workspaceQuery.GetCampaignsAsync();
m_State.Campaigns = campaigns.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList(); 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; state.SelectedCampaignId = null;
await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, null); await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, null);
return; 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)) if (preferredCampaignId.HasValue && campaignIds.Contains(preferredCampaignId.Value))
m_State.SelectedCampaignId = preferredCampaignId.Value; state.SelectedCampaignId = preferredCampaignId.Value;
else if (!m_State.SelectedCampaignId.HasValue || !campaignIds.Contains(m_State.SelectedCampaignId.Value)) else if (!state.SelectedCampaignId.HasValue || !campaignIds.Contains(state.SelectedCampaignId.Value))
m_State.SelectedCampaignId = m_State.Campaigns[0].Id; 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() public async Task ReloadCharacterCampaignOptionsAsync()
{ {
var campaignOptions = await m_WorkspaceQuery.GetCharacterCampaignOptionsAsync(); var campaignOptions = await workspaceQuery.GetCharacterCampaignOptionsAsync();
m_State.CharacterCampaignOptions = campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList(); state.CharacterCampaignOptions = campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList();
} }
public async Task RefreshCampaignRosterAsync() public async Task RefreshCampaignRosterAsync()
{ {
if (!m_State.SelectedCampaignId.HasValue) if (!state.SelectedCampaignId.HasValue)
{ {
m_State.SelectedCampaign = null; state.SelectedCampaign = null;
m_State.SelectedCharacterId = null; state.SelectedCharacterId = null;
return; return;
} }
m_State.SelectedCampaign = await m_WorkspaceQuery.GetCampaignAsync(m_State.SelectedCampaignId.Value); state.SelectedCampaign = await workspaceQuery.GetCampaignAsync(state.SelectedCampaignId.Value);
SyncSelectedCharacter(); SyncSelectedCharacter();
if (m_State.IsPlayScreen && m_State.PlaySelectedCharacterId.HasValue && m_State.SelectedCharacterId != m_State.PlaySelectedCharacterId) if (state.IsPlayScreen && state.PlaySelectedCharacterId.HasValue && state.SelectedCharacterId != state.PlaySelectedCharacterId)
m_State.SelectedCharacterId = m_State.PlaySelectedCharacterId; state.SelectedCharacterId = state.PlaySelectedCharacterId;
await m_EnsureSelectedCharacterActiveAsync(); await ensureSelectedCharacterActiveAsync();
} }
public async Task RefreshCampaignScopeAsync() public async Task RefreshCampaignScopeAsync()
{ {
if (!m_State.SelectedCampaignId.HasValue) if (!state.SelectedCampaignId.HasValue)
{ {
m_State.SelectedCampaign = null; state.SelectedCampaign = null;
m_State.SelectedCharacterSkills = []; state.SelectedCharacterSkills = [];
m_State.SelectedCharacterSkillGroups = []; state.SelectedCharacterSkillGroups = [];
m_State.CampaignLog = []; state.CampaignLog = [];
m_State.SelectedCharacterId = null; state.SelectedCharacterId = null;
m_State.ConnectionState = "offline"; state.ConnectionState = "offline";
m_State.CurrentCampaignState = null; state.CurrentCampaignState = null;
m_State.CampaignLogCursor = null; state.CampaignLogCursor = null;
m_ResetCampaignLogDetailState(); resetCampaignLogDetailState();
return; return;
} }
m_State.IsCampaignDataLoading = true; state.IsCampaignDataLoading = true;
try try
{ {
await RefreshCampaignRosterAsync(); await RefreshCampaignRosterAsync();
await m_RefreshSelectedCharacterSheetAsync(); await refreshSelectedCharacterSheetAsync();
await m_RefreshCampaignLogAsync(null); await refreshCampaignLogAsync(null);
m_ResetCampaignStateTracking(); resetCampaignStateTracking();
} }
catch (ApiRequestException ex) when (ex.StatusCode == 401) catch (ApiRequestException ex) when (ex.StatusCode == 401)
{ {
m_ClearAuthenticatedState(); clearAuthenticatedState();
await m_StopStateEventsAsync(); await stopStateEventsAsync();
await m_OnLoggedOutAsync("Session expired. Please log in again."); await onLoggedOutAsync("Session expired. Please log in again.");
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
m_Feedback.SetStatus(ex.Message, true); feedback.SetStatus(ex.Message, true);
} }
finally finally
{ {
m_State.IsCampaignDataLoading = false; state.IsCampaignDataLoading = false;
} }
} }
public async Task SetMobilePanelAsync(string panel) public async Task SetMobilePanelAsync(string panel)
{ {
m_State.MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character"; state.MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character";
await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, m_State.MobilePanel); await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, state.MobilePanel);
} }
private void SyncSelectedCharacter() 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; return;
} }
var candidateIds = m_State.SelectedCampaign.Characters.Select(character => character.Id).ToHashSet(); var candidateIds = state.SelectedCampaign.Characters.Select(character => character.Id).ToHashSet();
if (m_State.SelectedCharacterId.HasValue && candidateIds.Contains(m_State.SelectedCharacterId.Value)) if (state.SelectedCharacterId.HasValue && candidateIds.Contains(state.SelectedCharacterId.Value))
return; 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; 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 CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel"; 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; 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) public void SetStatus(string message, bool isError)
{ {
Announce(message); Announce(message);
@@ -16,18 +10,18 @@ public sealed class WorkspaceFeedbackService
public void Announce(string message) public void Announce(string message)
{ {
m_State.LiveAnnouncement = message; state.LiveAnnouncement = message;
} }
public void ClearToasts() public void ClearToasts()
{ {
m_State.Toasts.Clear(); state.Toasts.Clear();
} }
private void AddToast(string message, bool isError) private void AddToast(string message, bool isError)
{ {
var toastId = Guid.NewGuid(); var toastId = Guid.NewGuid();
m_State.Toasts.Add(new(toastId, message, isError)); state.Toasts.Add(new(toastId, message, isError));
_ = DismissToastLaterAsync(toastId); _ = DismissToastLaterAsync(toastId);
} }
@@ -35,12 +29,12 @@ public sealed class WorkspaceFeedbackService
{ {
await Task.Delay(ToastDurationMs); await Task.Delay(ToastDurationMs);
if (m_State.Toasts.RemoveAll(toast => toast.Id == toastId) == 0) if (state.Toasts.RemoveAll(toast => toast.Id == toastId) == 0)
return; return;
try try
{ {
await m_RequestRefreshAsync(); await requestRefreshAsync();
} }
catch (ObjectDisposedException) catch (ObjectDisposedException)
{ {
@@ -48,7 +42,4 @@ public sealed class WorkspaceFeedbackService
} }
private const int ToastDurationMs = 3200; 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; namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage] [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; if (state.StateRefreshInProgress)
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)
return; return;
if (!m_State.SelectedCampaignId.HasValue || state.CampaignId != m_State.SelectedCampaignId.Value) if (!state.SelectedCampaignId.HasValue || state1.CampaignId != state.SelectedCampaignId.Value)
return; return;
m_State.StateRefreshInProgress = true; state.StateRefreshInProgress = true;
try try
{ {
if (m_State.CurrentCampaignState is null) if (state.CurrentCampaignState is null)
{ {
m_State.CurrentCampaignState = state; state.CurrentCampaignState = state1;
return; return;
} }
var previousState = m_State.CurrentCampaignState; var previousState = state.CurrentCampaignState;
var previousSelectedCharacterId = m_State.SelectedCharacterId; var previousSelectedCharacterId = state.SelectedCharacterId;
var previousSelectedCharacterVersion = GetCharacterVersion(previousState, previousSelectedCharacterId); var previousSelectedCharacterVersion = GetCharacterVersion(previousState, previousSelectedCharacterId);
var rosterChanged = state.RosterVersion != previousState.RosterVersion; var rosterChanged = state1.RosterVersion != previousState.RosterVersion;
var logChanged = m_State.IsPlayScreen && state.LogVersion != previousState.LogVersion; var logChanged = state.IsPlayScreen && state1.LogVersion != previousState.LogVersion;
if (rosterChanged) if (rosterChanged)
await m_RefreshCampaignRosterAsync(); await refreshCampaignRosterAsync();
var selectedCharacterChanged = previousSelectedCharacterId != m_State.SelectedCharacterId; var selectedCharacterChanged = previousSelectedCharacterId != state.SelectedCharacterId;
var selectedCharacterVersionChanged = m_State.IsPlayScreen && !selectedCharacterChanged && GetCharacterVersion(state, m_State.SelectedCharacterId) != previousSelectedCharacterVersion; var selectedCharacterVersionChanged = state.IsPlayScreen && !selectedCharacterChanged && GetCharacterVersion(state1, state.SelectedCharacterId) != previousSelectedCharacterVersion;
if (m_State.IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged)) if (state.IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged))
await m_RefreshSelectedCharacterSheetAsync(); await refreshSelectedCharacterSheetAsync();
if (logChanged) if (logChanged)
await m_RefreshCampaignLogAsync(m_State.CampaignLogCursor); await refreshCampaignLogAsync(state.CampaignLogCursor);
m_State.CurrentCampaignState = state; state.CurrentCampaignState = state1;
} }
finally finally
{ {
m_State.StateRefreshInProgress = false; state.StateRefreshInProgress = false;
await m_RequestRefreshAsync(); 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", "connected" => "connected",
"reconnecting" => "reconnecting", "reconnecting" => "reconnecting",
_ => "offline" _ => "offline"
}; };
if (m_State.ConnectionState == "reconnecting") if (state.ConnectionState == "reconnecting")
m_Feedback.Announce("Reconnecting to live updates."); feedback.Announce("Reconnecting to live updates.");
if (m_State.ConnectionState == "offline") if (state.ConnectionState == "offline")
m_Feedback.Announce("Live updates offline. Use manual refresh."); feedback.Announce("Live updates offline. Use manual refresh.");
await m_RequestRefreshAsync(); await requestRefreshAsync();
} }
public async Task SyncStateEventsAsync() 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(); await StopStateEventsAsync();
m_State.ConnectionState = "offline"; state.ConnectionState = "offline";
return; return;
} }
await m_StartStateEventsAsync(m_State.SelectedCampaignId.Value); await startStateEventsAsync(state.SelectedCampaignId.Value);
m_State.ConnectionState = "reconnecting"; state.ConnectionState = "reconnecting";
} }
public async Task StopStateEventsAsync() public async Task StopStateEventsAsync()
{ {
if (!m_State.HasInteractiveRenderStarted) if (!state.HasInteractiveRenderStarted)
return; return;
await m_StopStateEventsCoreAsync(); await stopStateEventsCoreAsync();
} }
private static long GetCharacterVersion(CampaignStateSnapshot snapshot, Guid? characterId) 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; 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; namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage] [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) public async Task RefreshCampaignLogAsync(Guid? afterRollId = null)
{ {
if (!m_State.SelectedCampaignId.HasValue || !m_State.IsPlayScreen) if (!state.SelectedCampaignId.HasValue || !state.IsPlayScreen)
{ {
m_State.CampaignLog = []; state.CampaignLog = [];
m_State.CampaignLogCursor = null; state.CampaignLogCursor = null;
ResetCampaignLogDetailState(); ResetCampaignLogDetailState();
return; return;
} }
var previousLogCount = m_State.CampaignLog.Count; var previousLogCount = state.CampaignLog.Count;
var page = await m_WorkspaceQuery.GetCampaignLogPageAsync(m_State.SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize); var page = await workspaceQuery.GetCampaignLogPageAsync(state.SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize);
Guid? newestRollId = null; Guid? newestRollId = null;
if (!afterRollId.HasValue || page.ResetRequired) if (!afterRollId.HasValue || page.ResetRequired)
m_State.CampaignLog = page.Entries.ToList(); state.CampaignLog = page.Entries.ToList();
else if (page.Entries.Length > 0) else if (page.Entries.Length > 0)
{ {
m_State.CampaignLog.AddRange(page.Entries); state.CampaignLog.AddRange(page.Entries);
if (m_State.CampaignLog.Count > CampaignLogWindowSize) if (state.CampaignLog.Count > CampaignLogWindowSize)
m_State.CampaignLog = m_State.CampaignLog.TakeLast(CampaignLogWindowSize).ToList(); state.CampaignLog = state.CampaignLog.TakeLast(CampaignLogWindowSize).ToList();
} }
var shouldAutoExpandNewest = afterRollId.HasValue && page.Entries.Length > 0; 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; shouldAutoExpandNewest = true;
if (shouldAutoExpandNewest) if (shouldAutoExpandNewest)
{ {
newestRollId = page.Entries[^1].RollId; newestRollId = page.Entries[^1].RollId;
m_State.ExpandedCampaignLogRollId = newestRollId; state.ExpandedCampaignLogRollId = newestRollId;
m_State.FreshCampaignLogRollId = newestRollId; state.FreshCampaignLogRollId = newestRollId;
} }
else if (!afterRollId.HasValue) else if (!afterRollId.HasValue)
m_State.FreshCampaignLogRollId = null; state.FreshCampaignLogRollId = null;
m_State.CampaignLogCursor = page.Cursor ?? afterRollId; state.CampaignLogCursor = page.Cursor ?? afterRollId;
TrimCampaignLogDetails(); TrimCampaignLogDetails();
if (newestRollId.HasValue) if (newestRollId.HasValue)
@@ -60,23 +50,23 @@ public sealed class WorkspacePlayCoordinator
public async Task SelectCharacterAsync(Guid characterId) public async Task SelectCharacterAsync(Guid characterId)
{ {
m_State.SelectedCharacterId = characterId; state.SelectedCharacterId = characterId;
await RefreshSelectedCharacterSheetAsync(); await RefreshSelectedCharacterSheetAsync();
await EnsureSelectedCharacterActiveAsync(); await EnsureSelectedCharacterActiveAsync();
} }
public async Task RefreshSelectedCharacterSheetAsync() 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 = []; state.SelectedCharacterSkills = [];
m_State.SelectedCharacterSkillGroups = []; state.SelectedCharacterSkillGroups = [];
return; return;
} }
var sheet = await m_WorkspaceQuery.GetCharacterSheetAsync(m_State.SelectedCharacterId.Value); var sheet = await workspaceQuery.GetCharacterSheetAsync(state.SelectedCharacterId.Value);
m_State.SelectedCharacterSkillGroups = sheet.SkillGroups.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList(); state.SelectedCharacterSkillGroups = sheet.SkillGroups.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList();
m_State.SelectedCharacterSkills = sheet.Skills.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList(); state.SelectedCharacterSkills = sheet.Skills.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
} }
public Task EnsureSelectedCharacterActiveAsync() public Task EnsureSelectedCharacterActiveAsync()
@@ -86,16 +76,16 @@ public sealed class WorkspacePlayCoordinator
public async Task ToggleRollDetailAsync(Guid rollId) public async Task ToggleRollDetailAsync(Guid rollId)
{ {
if (m_State.ExpandedCampaignLogRollId == rollId) if (state.ExpandedCampaignLogRollId == rollId)
{ {
m_State.ExpandedCampaignLogRollId = null; state.ExpandedCampaignLogRollId = null;
if (m_State.FreshCampaignLogRollId == rollId) if (state.FreshCampaignLogRollId == rollId)
m_State.FreshCampaignLogRollId = null; state.FreshCampaignLogRollId = null;
return; return;
} }
m_State.ExpandedCampaignLogRollId = rollId; state.ExpandedCampaignLogRollId = rollId;
m_State.FreshCampaignLogRollId = null; state.FreshCampaignLogRollId = null;
await EnsureRollDetailLoadedAsync(rollId); await EnsureRollDetailLoadedAsync(rollId);
} }
@@ -103,212 +93,212 @@ public sealed class WorkspacePlayCoordinator
{ {
await RefreshSelectedCharacterSheetAsync(); await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking(); ResetCampaignStateTracking();
m_Feedback.SetStatus("Skill created.", false); feedback.SetStatus("Skill created.", false);
} }
public async Task OnSkillUpdatedAsync(Guid _) public async Task OnSkillUpdatedAsync(Guid _)
{ {
await RefreshSelectedCharacterSheetAsync(); await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking(); ResetCampaignStateTracking();
m_Feedback.SetStatus("Skill updated.", false); feedback.SetStatus("Skill updated.", false);
} }
public async Task OnSkillGroupCreatedAsync(Guid _) public async Task OnSkillGroupCreatedAsync(Guid _)
{ {
await RefreshSelectedCharacterSheetAsync(); await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking(); ResetCampaignStateTracking();
m_Feedback.SetStatus("Skill group created.", false); feedback.SetStatus("Skill group created.", false);
} }
public async Task OnSkillGroupUpdatedAsync(Guid _) public async Task OnSkillGroupUpdatedAsync(Guid _)
{ {
await RefreshSelectedCharacterSheetAsync(); await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking(); ResetCampaignStateTracking();
m_Feedback.SetStatus("Skill group updated.", false); feedback.SetStatus("Skill group updated.", false);
} }
public async Task OnSkillDeletedAsync(Guid _) public async Task OnSkillDeletedAsync(Guid _)
{ {
await RefreshSelectedCharacterSheetAsync(); await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking(); ResetCampaignStateTracking();
m_Feedback.SetStatus("Skill deleted.", false); feedback.SetStatus("Skill deleted.", false);
} }
public async Task OnSkillGroupDeletedAsync(Guid _) public async Task OnSkillGroupDeletedAsync(Guid _)
{ {
await RefreshSelectedCharacterSheetAsync(); await RefreshSelectedCharacterSheetAsync();
ResetCampaignStateTracking(); ResetCampaignStateTracking();
m_Feedback.SetStatus("Skill group deleted.", false); feedback.SetStatus("Skill group deleted.", false);
} }
public Task OnCharacterPanelErrorAsync(string message) public Task OnCharacterPanelErrorAsync(string message)
{ {
m_Feedback.SetStatus(message, true); feedback.SetStatus(message, true);
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task OnCampaignLogPanelErrorAsync(string message) public Task OnCampaignLogPanelErrorAsync(string message)
{ {
m_Feedback.SetStatus(message, true); feedback.SetStatus(message, true);
return Task.CompletedTask; return Task.CompletedTask;
} }
public async Task RollSkillAsync(Guid skillId) 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; return;
} }
m_State.IsMutating = true; state.IsMutating = true;
try 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); await HandleRecordedRollAsync(roll);
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
m_Feedback.SetStatus(ex.Message, true); feedback.SetStatus(ex.Message, true);
} }
finally finally
{ {
m_State.IsMutating = false; state.IsMutating = false;
} }
} }
public async Task OnCustomRollCreatedAsync(RollResult roll) public async Task OnCustomRollCreatedAsync(RollResult roll)
{ {
m_State.IsMutating = true; state.IsMutating = true;
try try
{ {
await HandleRecordedRollAsync(roll); await HandleRecordedRollAsync(roll);
} }
finally finally
{ {
m_State.IsMutating = false; state.IsMutating = false;
} }
} }
public bool CanEditSkill(CharacterSheetSkill skill) public bool CanEditSkill(CharacterSheetSkill skill)
{ {
if (m_State.SelectedCharacter is null) if (state.SelectedCharacter is null)
return false; return false;
return m_CanEditCharacter(m_State.SelectedCharacter); return canEditCharacter(state.SelectedCharacter);
} }
public CampaignRollDetail? ResolveRollDetail(Guid rollId) public CampaignRollDetail? ResolveRollDetail(Guid rollId)
{ {
return m_State.CampaignLogDetails.GetValueOrDefault(rollId); return state.CampaignLogDetails.GetValueOrDefault(rollId);
} }
public bool IsRollDetailLoading(Guid rollId) public bool IsRollDetailLoading(Guid rollId)
{ {
return m_State.CampaignLogDetailsLoading.Contains(rollId); return state.CampaignLogDetailsLoading.Contains(rollId);
} }
public string? GetRollDetailError(Guid rollId) public string? GetRollDetailError(Guid rollId)
{ {
return m_State.CampaignLogDetailErrors.GetValueOrDefault(rollId); return state.CampaignLogDetailErrors.GetValueOrDefault(rollId);
} }
public void ResetCampaignLogDetailState() public void ResetCampaignLogDetailState()
{ {
m_State.ExpandedCampaignLogRollId = null; state.ExpandedCampaignLogRollId = null;
m_State.FreshCampaignLogRollId = null; state.FreshCampaignLogRollId = null;
m_State.CampaignLogDetails.Clear(); state.CampaignLogDetails.Clear();
m_State.CampaignLogDetailsLoading.Clear(); state.CampaignLogDetailsLoading.Clear();
m_State.CampaignLogDetailErrors.Clear(); state.CampaignLogDetailErrors.Clear();
} }
public void ResetCampaignStateTracking() public void ResetCampaignStateTracking()
{ {
m_State.CurrentCampaignState = null; state.CurrentCampaignState = null;
} }
private async Task EnsureSelectedCharacterActiveCoreAsync() private async Task EnsureSelectedCharacterActiveCoreAsync()
{ {
if (!m_State.SelectedCharacterId.HasValue || m_State.SelectedCampaign is null) if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null)
return; return;
var character = m_State.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == m_State.SelectedCharacterId.Value); var character = state.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == state.SelectedCharacterId.Value);
if (character is null || !CanActivateCharacter(character, m_State.User) || m_State.ActiveCharacterId == character.Id) if (character is null || !CanActivateCharacter(character, state.User) || state.ActiveCharacterId == character.Id)
return; return;
try try
{ {
await m_ApiClient.RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate"); await apiClient.RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate");
m_State.ActiveCharacterId = character.Id; state.ActiveCharacterId = character.Id;
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
m_Feedback.SetStatus(ex.Message, true); feedback.SetStatus(ex.Message, true);
} }
} }
private async Task HandleRecordedRollAsync(RollResult roll) private async Task HandleRecordedRollAsync(RollResult roll)
{ {
m_State.LastRoll = roll; state.LastRoll = roll;
m_State.CampaignLogDetails[roll.RollId] = ToCampaignRollDetail(roll); state.CampaignLogDetails[roll.RollId] = ToCampaignRollDetail(roll);
m_State.CampaignLogDetailErrors.Remove(roll.RollId); state.CampaignLogDetailErrors.Remove(roll.RollId);
await RefreshCampaignLogAsync(m_State.CampaignLogCursor); await RefreshCampaignLogAsync(state.CampaignLogCursor);
PromoteFreshRoll(roll.RollId); PromoteFreshRoll(roll.RollId);
ResetCampaignStateTracking(); ResetCampaignStateTracking();
m_Feedback.SetStatus("Roll recorded.", false); feedback.SetStatus("Roll recorded.", false);
m_Feedback.Announce("Roll result updated."); feedback.Announce("Roll result updated.");
} }
private void TrimCampaignLogDetails() 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()) foreach (var rollId in state.CampaignLogDetails.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
m_State.CampaignLogDetails.Remove(rollId); state.CampaignLogDetails.Remove(rollId);
foreach (var rollId in m_State.CampaignLogDetailsLoading.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray()) foreach (var rollId in state.CampaignLogDetailsLoading.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
m_State.CampaignLogDetailsLoading.Remove(rollId); state.CampaignLogDetailsLoading.Remove(rollId);
foreach (var rollId in m_State.CampaignLogDetailErrors.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray()) foreach (var rollId in state.CampaignLogDetailErrors.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
m_State.CampaignLogDetailErrors.Remove(rollId); state.CampaignLogDetailErrors.Remove(rollId);
if (m_State.ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(m_State.ExpandedCampaignLogRollId.Value)) if (state.ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(state.ExpandedCampaignLogRollId.Value))
m_State.ExpandedCampaignLogRollId = null; state.ExpandedCampaignLogRollId = null;
if (m_State.FreshCampaignLogRollId.HasValue && !visibleRollIds.Contains(m_State.FreshCampaignLogRollId.Value)) if (state.FreshCampaignLogRollId.HasValue && !visibleRollIds.Contains(state.FreshCampaignLogRollId.Value))
m_State.FreshCampaignLogRollId = null; state.FreshCampaignLogRollId = null;
} }
private async Task EnsureRollDetailLoadedAsync(Guid rollId) private async Task EnsureRollDetailLoadedAsync(Guid rollId)
{ {
m_State.CampaignLogDetailErrors.Remove(rollId); state.CampaignLogDetailErrors.Remove(rollId);
if (m_State.CampaignLogDetails.ContainsKey(rollId) || m_State.CampaignLogDetailsLoading.Contains(rollId)) if (state.CampaignLogDetails.ContainsKey(rollId) || state.CampaignLogDetailsLoading.Contains(rollId))
return; return;
m_State.CampaignLogDetailsLoading.Add(rollId); state.CampaignLogDetailsLoading.Add(rollId);
try try
{ {
m_State.CampaignLogDetails[rollId] = await m_WorkspaceQuery.GetRollDetailAsync(rollId); state.CampaignLogDetails[rollId] = await workspaceQuery.GetRollDetailAsync(rollId);
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
m_State.CampaignLogDetailErrors[rollId] = ex.Message; state.CampaignLogDetailErrors[rollId] = ex.Message;
} }
finally finally
{ {
m_State.CampaignLogDetailsLoading.Remove(rollId); state.CampaignLogDetailsLoading.Remove(rollId);
await m_RequestRefreshAsync(); await requestRefreshAsync();
} }
} }
private void PromoteFreshRoll(Guid rollId) private void PromoteFreshRoll(Guid rollId)
{ {
if (!m_State.CampaignLog.Any(entry => entry.RollId == rollId)) if (!state.CampaignLog.Any(entry => entry.RollId == rollId))
return; return;
m_State.ExpandedCampaignLogRollId = rollId; state.ExpandedCampaignLogRollId = rollId;
m_State.FreshCampaignLogRollId = rollId; state.FreshCampaignLogRollId = rollId;
} }
private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user) private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user)
@@ -322,11 +312,4 @@ public sealed class WorkspacePlayCoordinator
} }
private const int CampaignLogWindowSize = 25; 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; 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() public async Task InitializeAsync()
{ {
var storedScreen = await m_JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey); var storedScreen = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
m_State.CurrentScreen = NormalizeRequestedScreen(storedScreen) ?? ScreenPlay; 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)) if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
m_State.MobilePanel = "log"; state.MobilePanel = "log";
var storedRollVisibility = await m_JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", RollVisibilitySessionKey); var storedRollVisibility = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", RollVisibilitySessionKey);
m_State.RollVisibility = NormalizeRollVisibility(storedRollVisibility); state.RollVisibility = NormalizeRollVisibility(storedRollVisibility);
Guid? preferredCampaignId = null; 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)) if (Guid.TryParse(storedCampaignId, out var parsedCampaignId))
preferredCampaignId = parsedCampaignId; preferredCampaignId = parsedCampaignId;
@@ -45,17 +27,17 @@ public sealed class WorkspaceSessionCoordinator
var reloaded = await ReloadAuthenticatedSessionAsync(preferredCampaignId); var reloaded = await ReloadAuthenticatedSessionAsync(preferredCampaignId);
if (!reloaded) if (!reloaded)
await m_OnLoggedOutAsync("Session expired. Please log in again."); await onLoggedOutAsync("Session expired. Please log in again.");
} }
public async Task RetryAfterHealthIssueAsync() public async Task RetryAfterHealthIssueAsync()
{ {
await CheckHealthAsync(); 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) 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 try
{ {
var usernames = await m_WorkspaceQuery.GetUsernamesAsync(); var usernames = await workspaceQuery.GetUsernamesAsync();
m_State.KnownUsernames = usernames.OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToList(); state.KnownUsernames = usernames.OrderBy(username => username, StringComparer.OrdinalIgnoreCase).ToList();
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
{ {
m_State.KnownUsernames = []; state.KnownUsernames = [];
m_Feedback.SetStatus(ex.Message, true); feedback.SetStatus(ex.Message, true);
} }
} }
public async Task LogoutAsync() public async Task LogoutAsync()
{ {
if (m_State.IsMutating) if (state.IsMutating)
return; return;
m_State.IsMutating = true; state.IsMutating = true;
try try
{ {
await m_ApiClient.RequestWithoutPayloadAsync("POST", "/api/auth/logout"); await apiClient.RequestWithoutPayloadAsync("POST", "/api/auth/logout");
} }
catch (ApiRequestException) catch (ApiRequestException)
{ {
} }
finally finally
{ {
m_State.IsMutating = false; state.IsMutating = false;
} }
ClearAuthenticatedState(); ClearAuthenticatedState();
await m_StopStateEventsAsync(); await stopStateEventsAsync();
await m_OnLoggedOutAsync("Logged out."); await onLoggedOutAsync("Logged out.");
} }
public async Task SwitchScreenAsync(string screen) public async Task SwitchScreenAsync(string screen)
{ {
var targetScreen = NormalizeRequestedScreen(screen) ?? ScreenPlay; 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; targetScreen = ScreenPlay;
m_State.CurrentScreen = targetScreen; state.CurrentScreen = targetScreen;
m_State.IsScreenMenuOpen = false; state.IsScreenMenuOpen = false;
await PersistScreenPreferenceAsync(m_State.CurrentScreen); await PersistScreenPreferenceAsync(state.CurrentScreen);
await m_RequestRefreshAsync(); await requestRefreshAsync();
if (m_State.User is not null) if (state.User is not null)
{ {
await m_RefreshCampaignScopeAsync(); await refreshCampaignScopeAsync();
await m_SyncStateEventsAsync(); await syncStateEventsAsync();
} }
if (m_State.IsAdminScreen) if (state.IsAdminScreen)
{ {
await m_EnsureAdminUsersLoadedAsync(); await ensureAdminUsersLoadedAsync();
await m_RequestRefreshAsync(); await requestRefreshAsync();
} }
} }
public async Task OnRollVisibilityChangedAsync(string visibility) public async Task OnRollVisibilityChangedAsync(string visibility)
{ {
m_State.RollVisibility = NormalizeRollVisibility(visibility); state.RollVisibility = NormalizeRollVisibility(visibility);
await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", RollVisibilitySessionKey, m_State.RollVisibility); await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", RollVisibilitySessionKey, state.RollVisibility);
} }
public void ClearAuthenticatedState() public void ClearAuthenticatedState()
{ {
m_State.User = null; state.User = null;
m_State.ActiveCharacterId = null; state.ActiveCharacterId = null;
m_State.SelectedCampaignId = null; state.SelectedCampaignId = null;
m_State.SelectedCampaign = null; state.SelectedCampaign = null;
m_State.Campaigns = []; state.Campaigns = [];
m_State.CharacterCampaignOptions = []; state.CharacterCampaignOptions = [];
m_State.SelectedCharacterSkills = []; state.SelectedCharacterSkills = [];
m_State.SelectedCharacterSkillGroups = []; state.SelectedCharacterSkillGroups = [];
m_State.CampaignLog = []; state.CampaignLog = [];
m_State.CampaignLogCursor = null; state.CampaignLogCursor = null;
m_ResetCampaignLogDetailState(); resetCampaignLogDetailState();
m_State.SelectedCharacterId = null; state.SelectedCharacterId = null;
m_State.LastRoll = null; state.LastRoll = null;
m_State.KnownUsernames = []; state.KnownUsernames = [];
m_State.ShowCreateCharacterModal = false; state.ShowCreateCharacterModal = false;
m_State.ShowEditCharacterModal = false; state.ShowEditCharacterModal = false;
m_State.CanEditCharacterOwner = false; state.CanEditCharacterOwner = false;
m_State.CreateCharacterInitialModel = new(); state.CreateCharacterInitialModel = new();
m_State.EditCharacterInitialModel = new(); state.EditCharacterInitialModel = new();
m_State.CreateCharacterFormVersion = 0; state.CreateCharacterFormVersion = 0;
m_State.EditCharacterFormVersion = 0; state.EditCharacterFormVersion = 0;
m_State.AdminUsers = []; state.AdminUsers = [];
m_State.HasLoadedAdminUsers = false; state.HasLoadedAdminUsers = false;
m_State.IsAdminDataLoading = false; state.IsAdminDataLoading = false;
m_Feedback.ClearToasts(); feedback.ClearToasts();
} }
private async Task CheckHealthAsync() private async Task CheckHealthAsync()
{ {
m_State.HasHealthIssue = false; state.HasHealthIssue = false;
m_State.HealthIssueMessage = string.Empty; state.HealthIssueMessage = string.Empty;
await Task.CompletedTask; await Task.CompletedTask;
} }
@@ -166,11 +148,11 @@ public sealed class WorkspaceSessionCoordinator
{ {
try try
{ {
m_State.Rulesets = (await m_WorkspaceQuery.GetRulesetsAsync()).ToList(); state.Rulesets = (await workspaceQuery.GetRulesetsAsync()).ToList();
} }
catch (ApiRequestException ex) 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) if (me is null)
{ {
ClearAuthenticatedState(); ClearAuthenticatedState();
await m_StopStateEventsAsync(); await stopStateEventsAsync();
return false; return false;
} }
m_State.User = me.User; state.User = me.User;
m_State.ActiveCharacterId = me.ActiveCharacterId; state.ActiveCharacterId = me.ActiveCharacterId;
await EnsureScreenAccessAsync(); await EnsureScreenAccessAsync();
await m_ReloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId); await reloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId);
await m_ReloadCharacterCampaignOptionsAsync(); await reloadCharacterCampaignOptionsAsync();
await m_RefreshCampaignScopeAsync(); await refreshCampaignScopeAsync();
await m_SyncStateEventsAsync(); await syncStateEventsAsync();
if (m_State.IsAdminScreen) if (state.IsAdminScreen)
await m_EnsureAdminUsersLoadedAsync(); await ensureAdminUsersLoadedAsync();
return true; return true;
} }
@@ -203,7 +185,7 @@ public sealed class WorkspaceSessionCoordinator
{ {
try try
{ {
return await m_WorkspaceQuery.GetMeAsync(); return await workspaceQuery.GetMeAsync();
} }
catch (ApiRequestException ex) when (ex.StatusCode == 401) catch (ApiRequestException ex) when (ex.StatusCode == 401)
{ {
@@ -213,24 +195,24 @@ public sealed class WorkspaceSessionCoordinator
private async Task EnsureScreenAccessAsync() private async Task EnsureScreenAccessAsync()
{ {
if (m_State.IsCurrentUserAdmin) if (state.IsCurrentUserAdmin)
return; return;
m_State.AdminUsers = []; state.AdminUsers = [];
m_State.HasLoadedAdminUsers = false; state.HasLoadedAdminUsers = false;
if (!m_State.IsAdminScreen) if (!state.IsAdminScreen)
return; return;
m_State.CurrentScreen = ScreenPlay; state.CurrentScreen = ScreenPlay;
await PersistScreenPreferenceAsync(m_State.CurrentScreen); await PersistScreenPreferenceAsync(state.CurrentScreen);
} }
private async Task PersistScreenPreferenceAsync(string screen) private async Task PersistScreenPreferenceAsync(string screen)
{ {
try try
{ {
await m_JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, screen); await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, screen);
} }
catch (JSDisconnectedException) catch (JSDisconnectedException)
{ {
@@ -271,19 +253,4 @@ public sealed class WorkspaceSessionCoordinator
private const string CampaignSessionKey = "campaign"; private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel"; private const string MobilePanelSessionKey = "play-panel";
private const string RollVisibilitySessionKey = "roll-visibility"; 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; namespace RpgRoller.Components;
public sealed class RpgRollerApiClient public sealed class RpgRollerApiClient(IJSRuntime js)
{ {
private sealed class JsApiResponse private sealed class JsApiResponse
{ {
@@ -15,14 +15,9 @@ public sealed class RpgRollerApiClient
public JsonElement Data { get; set; } 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) 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) if (!response.Ok)
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.", response.Code); 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) 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) if (!response.Ok)
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.", response.Code); throw new ApiRequestException(response.Status, response.Error ?? "Request failed.", response.Code);
} }
private static readonly JsonSerializerOptions JsonOptions = RpgRollerJson.CreateSerializerOptions(); 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) public int StatusCode { get; } = statusCode;
{ public string? ErrorCode { get; } = errorCode;
StatusCode = statusCode;
ErrorCode = errorCode;
}
public int StatusCode { get; }
public string? ErrorCode { get; }
} }

View File

@@ -3,72 +3,66 @@ using RpgRoller.Services;
namespace RpgRoller.Components; 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() public Task<MeResponse> GetMeAsync()
{ {
return Task.FromResult(GetValue(m_GameService.GetMe(GetRequiredSessionToken()))); return Task.FromResult(GetValue(gameService.GetMe(GetRequiredSessionToken())));
} }
public Task<IReadOnlyList<RulesetDefinition>> GetRulesetsAsync() public Task<IReadOnlyList<RulesetDefinition>> GetRulesetsAsync()
{ {
return Task.FromResult(m_GameService.GetRulesets()); return Task.FromResult(gameService.GetRulesets());
} }
public Task<IReadOnlyList<CampaignSummary>> GetCampaignsAsync() 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() 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) 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() 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) 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) 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) 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) 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() public Task<IReadOnlyList<AdminUserSummary>> GetAdminUsersAsync()
{ {
return Task.FromResult(GetValue(m_GameService.GetUsers(GetRequiredSessionToken()))); return Task.FromResult(GetValue(gameService.GetUsers(GetRequiredSessionToken())));
} }
private string GetRequiredSessionToken() private string GetRequiredSessionToken()
{ {
return m_SessionTokenAccessor.GetRequiredSessionToken(); return sessionTokenAccessor.GetRequiredSessionToken();
} }
private static T GetValue<T>(ServiceResult<T> result) private static T GetValue<T>(ServiceResult<T> result)
@@ -84,7 +78,4 @@ public sealed class WorkspaceQueryService
var statusCode = error.Code == "unauthorized" ? 401 : 400; var statusCode = error.Code == "unauthorized" ? 401 : 400;
return new(statusCode, error.Message, error.Code); 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; 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) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<UserAccount>(entity => modelBuilder.Entity<UserAccount>(entity =>

View File

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

View File

@@ -3,13 +3,8 @@ using RpgRoller.Domain;
namespace RpgRoller.Services; 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) public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int wildDice, bool allowFumble)
{ {
var initialDice = expression.DiceCount; var initialDice = expression.DiceCount;
@@ -20,7 +15,7 @@ public sealed class D6RollEngine
for (var i = 0; i < currentDice; i += 1) 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 isWild = i < wildDice;
var isCrit = false; var isCrit = false;
var isFumble = false; var isFumble = false;
@@ -89,6 +84,4 @@ public sealed class D6RollEngine
return (total, RollBreakdownFormatter.BuildBreakdown(includedDice, expression.Modifier, total), dieResults); 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; 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) public ServiceResult<UserSummary> Register(string username, string password, string displayName)
{ {
if (string.IsNullOrWhiteSpace(username)) if (string.IsNullOrWhiteSpace(username))
@@ -24,11 +17,11 @@ public sealed class GameAuthService
if (string.IsNullOrWhiteSpace(password) || password.Length < 8) if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
return ServiceResult<UserSummary>.Failure("invalid_password", "Password must be at least 8 characters."); 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 trimmedUsername = username.Trim();
var normalizedUsername = NormalizeUsername(trimmedUsername); 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."); return ServiceResult<UserSummary>.Failure("duplicate_username", "Username is already taken.");
var user = new UserAccount var user = new UserAccount
@@ -38,16 +31,16 @@ public sealed class GameAuthService
UsernameNormalized = normalizedUsername, UsernameNormalized = normalizedUsername,
DisplayName = displayName.Trim(), DisplayName = displayName.Trim(),
PasswordHash = string.Empty, PasswordHash = string.Empty,
Roles = m_StateStore.UsersById.Count == 0 ? UserRoles.Admin : string.Empty, Roles = stateStore.UsersById.Count == 0 ? UserRoles.Admin : string.Empty,
ActiveCharacterId = null ActiveCharacterId = null
}; };
user.PasswordHash = m_PasswordHasher.HashPassword(user, password); user.PasswordHash = passwordHasher.HashPassword(user, password);
m_StateStore.UsersById[user.Id] = user; stateStore.UsersById[user.Id] = user;
m_StateStore.UserIdsByUsername[user.UsernameNormalized] = user.Id; stateStore.UserIdsByUsername[user.UsernameNormalized] = user.Id;
m_PersistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
return ServiceResult<UserSummary>.Success(GameDtoMapper.ToUserSummary(user)); return ServiceResult<UserSummary>.Success(GameDtoMapper.ToUserSummary(user));
} }
} }
@@ -57,59 +50,59 @@ public sealed class GameAuthService
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or 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()); 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."); return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
var user = m_StateStore.UsersById[userId]; var user = stateStore.UsersById[userId];
var verification = m_PasswordHasher.VerifyHashedPassword(user, user.PasswordHash, password); var verification = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (verification == PasswordVerificationResult.Failed) if (verification == PasswordVerificationResult.Failed)
return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password."); return ServiceResult<(UserSummary User, string SessionToken)>.Failure("invalid_credentials", "Invalid username or password.");
if (verification == PasswordVerificationResult.SuccessRehashNeeded) if (verification == PasswordVerificationResult.SuccessRehashNeeded)
user.PasswordHash = m_PasswordHasher.HashPassword(user, password); user.PasswordHash = passwordHasher.HashPassword(user, password);
var session = CreateSession(userId); var session = CreateSession(userId);
m_PersistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
return ServiceResult<(UserSummary User, string SessionToken)>.Success((GameDtoMapper.ToUserSummary(user), session.Token)); return ServiceResult<(UserSummary User, string SessionToken)>.Success((GameDtoMapper.ToUserSummary(user), session.Token));
} }
} }
public void Logout(string sessionToken) public void Logout(string sessionToken)
{ {
lock (m_StateStore.Gate) lock (stateStore.Gate)
{ {
if (m_StateStore.SessionsByToken.Remove(sessionToken)) if (stateStore.SessionsByToken.Remove(sessionToken))
m_PersistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
} }
} }
public UserSummary? GetUserBySession(string sessionToken) 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); return user is null ? null : GameDtoMapper.ToUserSummary(user);
} }
} }
public ServiceResult<MeResponse> GetMe(string sessionToken) 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) if (user is null)
return ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in."); return ServiceResult<MeResponse>.Failure("unauthorized", "You must be logged in.");
Guid? campaignId = null; Guid? campaignId = null;
if (user.ActiveCharacterId is Guid activeCharacterId) 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; user.ActiveCharacterId = null;
m_PersistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
} }
else else
campaignId = activeCharacter.CampaignId; campaignId = activeCharacter.CampaignId;
@@ -129,7 +122,7 @@ public sealed class GameAuthService
CreatedAtUtc = DateTimeOffset.UtcNow CreatedAtUtc = DateTimeOffset.UtcNow
}; };
m_StateStore.SessionsByToken[token] = session; stateStore.SessionsByToken[token] = session;
return session; return session;
} }
@@ -137,8 +130,4 @@ public sealed class GameAuthService
{ {
return username.ToUpperInvariant(); 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; 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) public ServiceResult<CampaignSummary> CreateCampaign(string sessionToken, string name, string rulesetId)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
@@ -20,9 +14,9 @@ public sealed class GameCampaignService
if (ruleset is null) if (ruleset is null)
return ServiceResult<CampaignSummary>.Failure("invalid_ruleset", "Unknown ruleset."); 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) if (user is null)
return ServiceResult<CampaignSummary>.Failure("unauthorized", "You must be logged in."); return ServiceResult<CampaignSummary>.Failure("unauthorized", "You must be logged in.");
@@ -35,21 +29,21 @@ public sealed class GameCampaignService
Version = 1 Version = 1
}; };
m_StateStore.CampaignsById[campaign.Id] = campaign; stateStore.CampaignsById[campaign.Id] = campaign;
m_PersistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
return ServiceResult<CampaignSummary>.Success(GameDtoMapper.ToCampaignSummary(m_StateStore, campaign)); return ServiceResult<CampaignSummary>.Success(GameDtoMapper.ToCampaignSummary(stateStore, campaign));
} }
} }
public ServiceResult<IReadOnlyList<CampaignSummary>> GetCampaigns(string sessionToken) 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) if (user is null)
return ServiceResult<IReadOnlyList<CampaignSummary>>.Failure("unauthorized", "You must be logged in."); 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); return ServiceResult<IReadOnlyList<CampaignSummary>>.Success(results);
} }
@@ -57,13 +51,13 @@ public sealed class GameCampaignService
public ServiceResult<IReadOnlyList<CampaignOption>> GetCharacterCampaignOptions(string sessionToken) 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) if (user is null)
return ServiceResult<IReadOnlyList<CampaignOption>>.Failure("unauthorized", "You must be logged in."); 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); return ServiceResult<IReadOnlyList<CampaignOption>>.Success(options);
} }
@@ -71,50 +65,47 @@ public sealed class GameCampaignService
public ServiceResult<CampaignRoster> GetCampaign(string sessionToken, Guid campaignId) 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) if (!context.Succeeded)
return ServiceResult<CampaignRoster>.Failure(context.Error!.Code, context.Error.Message); return ServiceResult<CampaignRoster>.Failure(context.Error!.Code, context.Error.Message);
var (_, campaign) = context.Value; 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) 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) if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in."); 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."); return ServiceResult<bool>.Failure("campaign_not_found", "Campaign was not found.");
if (campaign.GmUserId != user.Id && !GameAuthorization.HasRole(user, UserRoles.Admin)) 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."); return ServiceResult<bool>.Failure("forbidden", "Only the campaign owner or admin can delete this campaign.");
DeleteCampaignLocked(campaignId); DeleteCampaignLocked(campaignId);
m_PersistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
return ServiceResult<bool>.Success(true); return ServiceResult<bool>.Success(true);
} }
} }
private void DeleteCampaignLocked(Guid campaignId) private void DeleteCampaignLocked(Guid campaignId)
{ {
if (!m_StateStore.CampaignsById.Remove(campaignId)) if (!stateStore.CampaignsById.Remove(campaignId))
return; 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) foreach (var characterId in affectedCharacterIds)
m_StateStore.CharactersById[characterId].CampaignId = null; stateStore.CharactersById[characterId].CampaignId = null;
m_StateStore.RollLog.RemoveAll(entry => entry.CampaignId == campaignId); stateStore.RollLog.RemoveAll(entry => entry.CampaignId == campaignId);
m_StateStore.CampaignStateById.Remove(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; 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) public ServiceResult<CharacterSummary> CreateCharacter(string sessionToken, string name, Guid campaignId)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required."); 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) if (user is null)
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in."); 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."); return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
var character = new Character var character = new Character
@@ -33,12 +27,12 @@ public sealed class GameCharacterService
Name = name.Trim() Name = name.Trim()
}; };
m_StateStore.CharactersById[character.Id] = character; stateStore.CharactersById[character.Id] = character;
m_StateStore.AddCharacterStateLocked(character.CampaignId, character.Id); stateStore.AddCharacterStateLocked(character.CampaignId, character.Id);
m_StateStore.TouchRosterLocked(character.CampaignId); stateStore.TouchRosterLocked(character.CampaignId);
m_PersistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
return ServiceResult<CharacterSummary>.Success(GameDtoMapper.ToCharacterSummary(m_StateStore, character)); return ServiceResult<CharacterSummary>.Success(GameDtoMapper.ToCharacterSummary(stateStore, character));
} }
} }
@@ -47,22 +41,22 @@ public sealed class GameCharacterService
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<CharacterSummary>.Failure("invalid_character_name", "Character name is required."); 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) if (user is null)
return ServiceResult<CharacterSummary>.Failure("unauthorized", "You must be logged in."); 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."); return ServiceResult<CharacterSummary>.Failure("character_not_found", "Character was not found.");
Campaign? targetCampaign = null; 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."); return ServiceResult<CharacterSummary>.Failure("campaign_not_found", "Campaign was not found.");
var isOwner = character.OwnerUserId == user.Id; var isOwner = character.OwnerUserId == user.Id;
var isAdmin = GameAuthorization.HasRole(user, UserRoles.Admin); 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; var isTargetGm = targetCampaign is not null && targetCampaign.GmUserId == user.Id;
if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm) if (!isOwner && !isAdmin && !isSourceGm && !isTargetGm)
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the owner, GM, or admin can edit this character."); 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 trimmedOwnerUsername = ownerUsername.Trim();
var normalizedOwnerUsername = NormalizeUsername(trimmedOwnerUsername); 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."); return ServiceResult<CharacterSummary>.Failure("owner_not_found", "Owner username was not found.");
if (targetOwnerUserId != character.OwnerUserId && !isAdmin && !isSourceGm && !isTargetGm) if (targetOwnerUserId != character.OwnerUserId && !isAdmin && !isSourceGm && !isTargetGm)
return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM or admin can change character owner."); return ServiceResult<CharacterSummary>.Failure("forbidden", "Only the GM or admin can change character owner.");
character.OwnerUserId = targetOwnerUserId; 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; previousOwner.ActiveCharacterId = null;
} }
if (sourceCampaignId != character.CampaignId) if (sourceCampaignId != character.CampaignId)
{ {
m_StateStore.RemoveCharacterStateLocked(sourceCampaignId, character.Id); stateStore.RemoveCharacterStateLocked(sourceCampaignId, character.Id);
m_StateStore.AddCharacterStateLocked(character.CampaignId, character.Id); stateStore.AddCharacterStateLocked(character.CampaignId, character.Id);
} }
m_StateStore.TouchRosterLocked(sourceCampaignId); stateStore.TouchRosterLocked(sourceCampaignId);
if (sourceCampaignId != character.CampaignId) if (sourceCampaignId != character.CampaignId)
m_StateStore.TouchRosterLocked(character.CampaignId); stateStore.TouchRosterLocked(character.CampaignId);
m_PersistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
return ServiceResult<CharacterSummary>.Success(GameDtoMapper.ToCharacterSummary(m_StateStore, character)); return ServiceResult<CharacterSummary>.Success(GameDtoMapper.ToCharacterSummary(stateStore, character));
} }
} }
public ServiceResult<bool> DeleteCharacter(string sessionToken, Guid characterId) 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) if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in."); 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."); return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
var isOwner = character.OwnerUserId == user.Id; 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."); return ServiceResult<bool>.Failure("forbidden", "Only the owner or admin can delete this character.");
DeleteCharacterLocked(characterId); DeleteCharacterLocked(characterId);
m_PersistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
return ServiceResult<bool>.Success(true); return ServiceResult<bool>.Success(true);
} }
} }
public ServiceResult<bool> ActivateCharacter(string sessionToken, Guid characterId) 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) if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in."); 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."); return ServiceResult<bool>.Failure("character_not_found", "Character was not found.");
if (character.OwnerUserId != user.Id) if (character.OwnerUserId != user.Id)
return ServiceResult<bool>.Failure("forbidden", "You can activate only your own character."); return ServiceResult<bool>.Failure("forbidden", "You can activate only your own character.");
user.ActiveCharacterId = character.Id; user.ActiveCharacterId = character.Id;
m_PersistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
return ServiceResult<bool>.Success(true); return ServiceResult<bool>.Success(true);
} }
} }
public ServiceResult<IReadOnlyList<CharacterSummary>> GetOwnCharacters(string sessionToken) 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) if (user is null)
return ServiceResult<IReadOnlyList<CharacterSummary>>.Failure("unauthorized", "You must be logged in."); 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); return ServiceResult<IReadOnlyList<CharacterSummary>>.Success(characters);
} }
@@ -160,34 +154,31 @@ public sealed class GameCharacterService
private void DeleteCharacterLocked(Guid characterId) private void DeleteCharacterLocked(Guid characterId)
{ {
if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character)) if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
return; return;
var campaignId = character.CampaignId; 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) 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) 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; user.ActiveCharacterId = null;
m_StateStore.RemoveCharacterStateLocked(campaignId, characterId); stateStore.RemoveCharacterStateLocked(campaignId, characterId);
m_StateStore.TouchRosterLocked(campaignId); stateStore.TouchRosterLocked(campaignId);
} }
private static string NormalizeUsername(string username) private static string NormalizeUsername(string username)
{ {
return username.ToUpperInvariant(); 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) public static bool TryResolveCharacterCampaignLocked(GameStateStore stateStore, Character character, out Campaign campaign, out ServiceError? error)
{ {
campaign = default!; campaign = null!;
if (!character.CampaignId.HasValue || !stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign) || resolvedCampaign is null) if (!character.CampaignId.HasValue || !stateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign))
{ {
error = new("character_not_in_campaign", "Character is not linked to a campaign."); error = new("character_not_in_campaign", "Character is not linked to a campaign.");
return false; return false;

View File

@@ -4,17 +4,11 @@ using RpgRoller.Domain;
namespace RpgRoller.Services; 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() public void LoadStateFromDatabase()
{ {
using var db = m_DbContextFactory.CreateDbContext(); using var db = dbContextFactory.CreateDbContext();
var users = db.Users.AsNoTracking().ToList(); var users = db.Users.AsNoTracking().ToList();
var sessions = db.Sessions.AsNoTracking().ToList(); var sessions = db.Sessions.AsNoTracking().ToList();
var campaigns = db.Campaigns.AsNoTracking().ToList(); var campaigns = db.Campaigns.AsNoTracking().ToList();
@@ -23,16 +17,16 @@ public sealed class GamePersistenceService
var skills = db.Skills.AsNoTracking().ToList(); var skills = db.Skills.AsNoTracking().ToList();
var logEntries = db.RollLogEntries.AsNoTracking().ToList().OrderBy(x => x.TimestampUtc).ThenBy(x => x.Id).ToList(); var logEntries = db.RollLogEntries.AsNoTracking().ToList().OrderBy(x => x.TimestampUtc).ThenBy(x => x.Id).ToList();
lock (m_StateStore.Gate) lock (stateStore.Gate)
{ {
m_StateStore.UsersById.Clear(); stateStore.UsersById.Clear();
m_StateStore.UserIdsByUsername.Clear(); stateStore.UserIdsByUsername.Clear();
m_StateStore.SessionsByToken.Clear(); stateStore.SessionsByToken.Clear();
m_StateStore.CampaignsById.Clear(); stateStore.CampaignsById.Clear();
m_StateStore.CharactersById.Clear(); stateStore.CharactersById.Clear();
m_StateStore.SkillGroupsById.Clear(); stateStore.SkillGroupsById.Clear();
m_StateStore.SkillsById.Clear(); stateStore.SkillsById.Clear();
m_StateStore.RollLog.Clear(); stateStore.RollLog.Clear();
foreach (var user in users) 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)), Roles = string.IsNullOrWhiteSpace(user.Roles) ? string.Empty : RoleSerializer.Serialize(RoleSerializer.Parse(user.Roles)),
ActiveCharacterId = user.ActiveCharacterId ActiveCharacterId = user.ActiveCharacterId
}; };
m_StateStore.UsersById[storedUser.Id] = storedUser; stateStore.UsersById[storedUser.Id] = storedUser;
m_StateStore.UserIdsByUsername[normalizedUsername] = storedUser.Id; stateStore.UserIdsByUsername[normalizedUsername] = storedUser.Id;
} }
foreach (var session in sessions) foreach (var session in sessions)
{ {
if (m_StateStore.UsersById.ContainsKey(session.UserId)) if (stateStore.UsersById.ContainsKey(session.UserId))
m_StateStore.SessionsByToken[session.Token] = GameStateCloneFactory.CloneSession(session); stateStore.SessionsByToken[session.Token] = GameStateCloneFactory.CloneSession(session);
} }
foreach (var campaign in campaigns) 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) 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) 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) 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() public void PersistStateLocked()
{ {
using var db = m_DbContextFactory.CreateDbContext(); using var db = dbContextFactory.CreateDbContext();
using var transaction = db.Database.BeginTransaction(); using var transaction = db.Database.BeginTransaction();
db.RollLogEntries.ExecuteDelete(); db.RollLogEntries.ExecuteDelete();
@@ -87,13 +81,16 @@ public sealed class GamePersistenceService
db.Sessions.ExecuteDelete(); db.Sessions.ExecuteDelete();
db.Users.ExecuteDelete(); db.Users.ExecuteDelete();
db.Users.AddRange(m_StateStore.UsersById.Values.Select(GameStateCloneFactory.CloneUser)); lock (stateStore.Gate)
db.Sessions.AddRange(m_StateStore.SessionsByToken.Values.Select(GameStateCloneFactory.CloneSession)); {
db.Campaigns.AddRange(m_StateStore.CampaignsById.Values.Select(GameStateCloneFactory.CloneCampaign)); db.Users.AddRange(stateStore.UsersById.Values.Select(GameStateCloneFactory.CloneUser));
db.Characters.AddRange(m_StateStore.CharactersById.Values.Select(GameStateCloneFactory.CloneCharacter)); db.Sessions.AddRange(stateStore.SessionsByToken.Values.Select(GameStateCloneFactory.CloneSession));
db.SkillGroups.AddRange(m_StateStore.SkillGroupsById.Values.Select(GameStateCloneFactory.CloneSkillGroup)); db.Campaigns.AddRange(stateStore.CampaignsById.Values.Select(GameStateCloneFactory.CloneCampaign));
db.Skills.AddRange(m_StateStore.SkillsById.Values.Select(GameStateCloneFactory.CloneSkill)); db.Characters.AddRange(stateStore.CharactersById.Values.Select(GameStateCloneFactory.CloneCharacter));
db.RollLogEntries.AddRange(m_StateStore.RollLog.Select(GameStateCloneFactory.CloneRollLogEntry)); 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(); db.SaveChanges();
transaction.Commit(); transaction.Commit();
@@ -103,7 +100,4 @@ public sealed class GamePersistenceService
{ {
return username.ToUpperInvariant(); 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; 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) 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) if (user is null)
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in."); 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."); return ServiceResult<RollResult>.Failure("skill_not_found", "Skill was not found.");
var character = m_StateStore.CharactersById[skill.CharacterId]; var character = stateStore.CharactersById[skill.CharacterId];
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); return ServiceResult<RollResult>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign)) 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) 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) if (user is null)
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in."); 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."); 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); return ServiceResult<RollResult>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign)) if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
@@ -78,13 +70,13 @@ public sealed class GameRollService
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId) 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) if (!context.Succeeded)
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Failure(context.Error!.Code, context.Error.Message); 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(); var entries = GetVisibleCampaignLogEntriesLocked(user, campaign).TakeLast(CampaignLogHistoryWindowSize).Select(ToLogEntry).ToArray();
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries); 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) 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) if (!context.Succeeded)
return ServiceResult<CampaignLogPage>.Failure(context.Error!.Code, context.Error.Message); 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 pageSize = NormalizeCampaignLogPageSize(limit);
var visibleEntries = GetVisibleCampaignLogEntriesLocked(user, campaign).ToArray(); var visibleEntries = GetVisibleCampaignLogEntriesLocked(user, campaign).ToArray();
@@ -133,17 +125,17 @@ public sealed class GameRollService
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId) 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) if (user is null)
return ServiceResult<CampaignRollDetail>.Failure("unauthorized", "You must be logged in."); 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) if (entry is null)
return ServiceResult<CampaignRollDetail>.Failure("roll_not_found", "Roll was not found."); 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>.Failure("roll_not_found", "Roll was not found.");
return ServiceResult<CampaignRollDetail>.Success(GameDtoMapper.ToCampaignRollDetail(entry, DeserializeDice(entry.Dice).ToArray())); 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) 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) if (!context.Succeeded)
return ServiceResult<CampaignStateSnapshot>.Failure(context.Error!.Code, context.Error.Message); 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 TimestampUtc = DateTimeOffset.UtcNow
}; };
m_StateStore.RollLog.Add(entry); stateStore.RollLog.Add(entry);
m_StateStore.TouchLogLocked(campaign.Id); stateStore.TouchLogLocked(campaign.Id);
m_PersistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
return ServiceResult<RollResult>.Success(GameDtoMapper.ToRollResult(entry, roll.Dice)); 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) 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) private CampaignLogEntry ToLogEntry(RollLogEntry entry)
{ {
var dice = DeserializeDice(entry.Dice); 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 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); 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) private CampaignLogListEntry ToLogListEntry(UserAccount user, Campaign campaign, RollLogEntry entry)
{ {
var dice = DeserializeDice(entry.Dice); 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 skillName = ResolveLoggedSkillName(entry);
var loggedExpression = ResolveLoggedExpression(entry); var loggedExpression = ResolveLoggedExpression(entry);
var eventBadges = CampaignLogSummaryBuilder.BuildCompactLogEventBadges(campaign.Ruleset, loggedExpression, dice); var eventBadges = CampaignLogSummaryBuilder.BuildCompactLogEventBadges(campaign.Ruleset, loggedExpression, dice);
@@ -226,7 +218,7 @@ public sealed class GameRollService
if (entry.SkillId == CustomRollSkillId) if (entry.SkillId == CustomRollSkillId)
return CustomRollLabel; 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) private string? ResolveLoggedExpression(RollLogEntry entry)
@@ -234,7 +226,7 @@ public sealed class GameRollService
if (entry.SkillId == CustomRollSkillId) if (entry.SkillId == CustomRollSkillId)
return CampaignLogSummaryBuilder.ExtractCustomRollExpression(entry.Breakdown, CustomRollBreakdownSeparator); 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) private string ResolveLogRollerLabel(UserAccount user, Campaign campaign, RollLogEntry entry)
@@ -245,7 +237,7 @@ public sealed class GameRollService
if (entry.RollerUserId == campaign.GmUserId) if (entry.RollerUserId == campaign.GmUserId)
return "GM"; 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) 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 const string CustomRollLabel = "Custom roll";
private static readonly Guid CustomRollSkillId = Guid.Empty; private static readonly Guid CustomRollSkillId = Guid.Empty;
private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions(); private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions();
private readonly IDiceRoller m_DiceRoller; private readonly IDiceRoller m_DiceRoller = diceRoller;
private readonly GamePersistenceService m_PersistenceService; private readonly RollEngine m_RollEngine = new(new(diceRoller), new(diceRoller), new(diceRoller));
private readonly RollEngine m_RollEngine;
private readonly GameStateStore m_StateStore;
} }

View File

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

View File

@@ -3,29 +3,23 @@ using RpgRoller.Domain;
namespace RpgRoller.Services; 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) public ServiceResult<SkillGroupSummary> CreateSkillGroup(string sessionToken, Guid characterId, string name, string diceRollDefinition, int wildDice, bool allowFumble, int? fumbleRange = null)
{ {
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required."); 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) if (user is null)
return ServiceResult<SkillGroupSummary>.Failure("unauthorized", "You must be logged in."); 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."); 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); return ServiceResult<SkillGroupSummary>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign)) if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
@@ -40,16 +34,16 @@ public sealed class GameSkillService
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
CharacterId = character.Id, CharacterId = character.Id,
Name = name.Trim(), Name = name.Trim(),
DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression, DiceRollDefinition = prototypeValidation.Value.CanonicalExpression,
WildDice = prototypeValidation.Value.WildDice, WildDice = prototypeValidation.Value.WildDice,
AllowFumble = prototypeValidation.Value.AllowFumble, AllowFumble = prototypeValidation.Value.AllowFumble,
FumbleRange = prototypeValidation.Value.FumbleRange FumbleRange = prototypeValidation.Value.FumbleRange
}; };
m_StateStore.SkillGroupsById[group.Id] = group; stateStore.SkillGroupsById[group.Id] = group;
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)); return ServiceResult<SkillGroupSummary>.Success(GameDtoMapper.ToSkillGroupSummary(group));
} }
} }
@@ -59,17 +53,17 @@ public sealed class GameSkillService
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillGroupSummary>.Failure("invalid_skill_group_name", "Skill group name is required."); 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) if (user is null)
return ServiceResult<SkillGroupSummary>.Failure("unauthorized", "You must be logged in."); 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."); return ServiceResult<SkillGroupSummary>.Failure("skill_group_not_found", "Skill group was not found.");
var character = m_StateStore.CharactersById[group.CharacterId]; var character = stateStore.CharactersById[group.CharacterId];
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); return ServiceResult<SkillGroupSummary>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign)) 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); return ServiceResult<SkillGroupSummary>.Failure(prototypeValidation.Error!.Code, prototypeValidation.Error.Message);
group.Name = name.Trim(); group.Name = name.Trim();
group.DiceRollDefinition = prototypeValidation.Value!.CanonicalExpression; group.DiceRollDefinition = prototypeValidation.Value.CanonicalExpression;
group.WildDice = prototypeValidation.Value.WildDice; group.WildDice = prototypeValidation.Value.WildDice;
group.AllowFumble = prototypeValidation.Value.AllowFumble; group.AllowFumble = prototypeValidation.Value.AllowFumble;
group.FumbleRange = prototypeValidation.Value.FumbleRange; 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)); return ServiceResult<SkillGroupSummary>.Success(GameDtoMapper.ToSkillGroupSummary(group));
} }
} }
public ServiceResult<bool> DeleteSkillGroup(string sessionToken, Guid skillGroupId) 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) if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in."); 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."); return ServiceResult<bool>.Failure("skill_group_not_found", "Skill group was not found.");
var character = m_StateStore.CharactersById[group.CharacterId]; var character = stateStore.CharactersById[group.CharacterId];
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<bool>.Failure(campaignError!.Code, campaignError.Message); return ServiceResult<bool>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign)) if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
return ServiceResult<bool>.Failure("forbidden", "Only the owner or GM can manage skill groups."); 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; skill.SkillGroupId = null;
m_StateStore.SkillGroupsById.Remove(group.Id); stateStore.SkillGroupsById.Remove(group.Id);
m_StateStore.TouchCharacterLocked(campaign.Id, character.Id); stateStore.TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
return ServiceResult<bool>.Success(true); return ServiceResult<bool>.Success(true);
} }
} }
@@ -125,16 +119,16 @@ public sealed class GameSkillService
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required."); 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) if (user is null)
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in."); 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."); 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); return ServiceResult<SkillSummary>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign)) if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
@@ -154,16 +148,16 @@ public sealed class GameSkillService
CharacterId = character.Id, CharacterId = character.Id,
SkillGroupId = resolvedSkillGroupId.Value, SkillGroupId = resolvedSkillGroupId.Value,
Name = name.Trim(), Name = name.Trim(),
DiceRollDefinition = skillValidation.Value!.CanonicalExpression, DiceRollDefinition = skillValidation.Value.CanonicalExpression,
WildDice = skillValidation.Value.WildDice, WildDice = skillValidation.Value.WildDice,
AllowFumble = skillValidation.Value.AllowFumble, AllowFumble = skillValidation.Value.AllowFumble,
FumbleRange = skillValidation.Value.FumbleRange FumbleRange = skillValidation.Value.FumbleRange
}; };
m_StateStore.SkillsById[skill.Id] = skill; stateStore.SkillsById[skill.Id] = skill;
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)); return ServiceResult<SkillSummary>.Success(GameDtoMapper.ToSkillSummary(skill));
} }
} }
@@ -173,17 +167,17 @@ public sealed class GameSkillService
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return ServiceResult<SkillSummary>.Failure("invalid_skill_name", "Skill name is required."); 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) if (user is null)
return ServiceResult<SkillSummary>.Failure("unauthorized", "You must be logged in."); 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."); return ServiceResult<SkillSummary>.Failure("skill_not_found", "Skill was not found.");
var character = m_StateStore.CharactersById[skill.CharacterId]; var character = stateStore.CharactersById[skill.CharacterId];
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); return ServiceResult<SkillSummary>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign)) 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); return ServiceResult<SkillSummary>.Failure(resolvedSkillGroupId.Error!.Code, resolvedSkillGroupId.Error.Message);
skill.Name = name.Trim(); skill.Name = name.Trim();
skill.DiceRollDefinition = skillValidation.Value!.CanonicalExpression; skill.DiceRollDefinition = skillValidation.Value.CanonicalExpression;
skill.WildDice = skillValidation.Value.WildDice; skill.WildDice = skillValidation.Value.WildDice;
skill.AllowFumble = skillValidation.Value.AllowFumble; skill.AllowFumble = skillValidation.Value.AllowFumble;
skill.FumbleRange = skillValidation.Value.FumbleRange; skill.FumbleRange = skillValidation.Value.FumbleRange;
skill.SkillGroupId = resolvedSkillGroupId.Value; 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)); return ServiceResult<SkillSummary>.Success(GameDtoMapper.ToSkillSummary(skill));
} }
} }
public ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId) 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) if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in."); 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."); return ServiceResult<bool>.Failure("skill_not_found", "Skill was not found.");
var character = m_StateStore.CharactersById[skill.CharacterId]; var character = stateStore.CharactersById[skill.CharacterId];
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<bool>.Failure(campaignError!.Code, campaignError.Message); return ServiceResult<bool>.Failure(campaignError!.Code, campaignError.Message);
if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign)) if (!GameAuthorization.CanEditCharacter(user.Id, character, campaign))
return ServiceResult<bool>.Failure("forbidden", "Only the owner or GM can edit skills."); return ServiceResult<bool>.Failure("forbidden", "Only the owner or GM can edit skills.");
m_StateStore.SkillsById.Remove(skill.Id); stateStore.SkillsById.Remove(skill.Id);
m_StateStore.TouchCharacterLocked(campaign.Id, character.Id); stateStore.TouchCharacterLocked(campaign.Id, character.Id);
m_PersistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
return ServiceResult<bool>.Success(true); return ServiceResult<bool>.Success(true);
} }
} }
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId) 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) if (user is null)
return ServiceResult<CharacterSheet>.Failure("unauthorized", "You must be logged in."); 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."); 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); 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>.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) if (!requestedSkillGroupId.HasValue)
return ServiceResult<Guid?>.Success(null); 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."); return ServiceResult<Guid?>.Failure("skill_group_not_found", "Skill group was not found.");
if (skillGroup.CharacterId != characterId) if (skillGroup.CharacterId != characterId)
@@ -270,7 +264,4 @@ public sealed class GameSkillService
return ServiceResult<Guid?>.Success(skillGroup.Id); 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; 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) 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) if (user is null)
return ServiceResult<IReadOnlyList<string>>.Failure("unauthorized", "You must be logged in."); 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); return ServiceResult<IReadOnlyList<string>>.Success(usernames);
} }
@@ -27,16 +21,16 @@ public sealed class GameUserAdministrationService
public ServiceResult<IReadOnlyList<AdminUserSummary>> GetUsers(string sessionToken) 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) if (user is null)
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("unauthorized", "You must be logged in."); return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("unauthorized", "You must be logged in.");
if (!GameAuthorization.HasRole(user, UserRoles.Admin)) if (!GameAuthorization.HasRole(user, UserRoles.Admin))
return ServiceResult<IReadOnlyList<AdminUserSummary>>.Failure("forbidden", "Admin role is required."); 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); 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) 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) if (user is null)
return ServiceResult<AdminUserSummary>.Failure("unauthorized", "You must be logged in."); return ServiceResult<AdminUserSummary>.Failure("unauthorized", "You must be logged in.");
if (!GameAuthorization.HasRole(user, UserRoles.Admin)) if (!GameAuthorization.HasRole(user, UserRoles.Admin))
return ServiceResult<AdminUserSummary>.Failure("forbidden", "Admin role is required."); 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."); return ServiceResult<AdminUserSummary>.Failure("user_not_found", "User was not found.");
var normalizedRoles = RoleSerializer.Normalize(roles); 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."); return ServiceResult<AdminUserSummary>.Failure("forbidden", "You cannot remove your own admin role.");
targetUser.Roles = RoleSerializer.Serialize(normalizedRoles); targetUser.Roles = RoleSerializer.Serialize(normalizedRoles);
m_PersistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
return ServiceResult<AdminUserSummary>.Success(GameDtoMapper.ToAdminUserSummary(targetUser)); return ServiceResult<AdminUserSummary>.Success(GameDtoMapper.ToAdminUserSummary(targetUser));
} }
} }
public ServiceResult<bool> DeleteUser(string sessionToken, Guid userId) 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) if (user is null)
return ServiceResult<bool>.Failure("unauthorized", "You must be logged in."); return ServiceResult<bool>.Failure("unauthorized", "You must be logged in.");
@@ -83,72 +77,69 @@ public sealed class GameUserAdministrationService
if (user.Id == userId) if (user.Id == userId)
return ServiceResult<bool>.Failure("forbidden", "You cannot delete your own account."); 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."); 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 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) foreach (var campaignId in gmCampaignIds)
DeleteCampaignLocked(campaignId); 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) foreach (var characterId in ownedCharacterIds)
DeleteCharacterLocked(characterId); 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) foreach (var token in staleSessions)
m_StateStore.SessionsByToken.Remove(token); stateStore.SessionsByToken.Remove(token);
m_StateStore.UsersById.Remove(targetUser.Id); stateStore.UsersById.Remove(targetUser.Id);
m_StateStore.UserIdsByUsername.Remove(targetUser.UsernameNormalized); stateStore.UserIdsByUsername.Remove(targetUser.UsernameNormalized);
m_PersistenceService.PersistStateLocked(); persistenceService.PersistStateLocked();
return ServiceResult<bool>.Success(true); return ServiceResult<bool>.Success(true);
} }
} }
private void DeleteCampaignLocked(Guid campaignId) private void DeleteCampaignLocked(Guid campaignId)
{ {
if (!m_StateStore.CampaignsById.Remove(campaignId)) if (!stateStore.CampaignsById.Remove(campaignId))
return; 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) foreach (var characterId in affectedCharacterIds)
m_StateStore.CharactersById[characterId].CampaignId = null; stateStore.CharactersById[characterId].CampaignId = null;
m_StateStore.RollLog.RemoveAll(entry => entry.CampaignId == campaignId); stateStore.RollLog.RemoveAll(entry => entry.CampaignId == campaignId);
m_StateStore.CampaignStateById.Remove(campaignId); stateStore.CampaignStateById.Remove(campaignId);
} }
private void DeleteCharacterLocked(Guid characterId) private void DeleteCharacterLocked(Guid characterId)
{ {
if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character)) if (!stateStore.CharactersById.TryGetValue(characterId, out var character))
return; return;
var campaignId = character.CampaignId; 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) 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) 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; account.ActiveCharacterId = null;
m_StateStore.RemoveCharacterStateLocked(campaignId, characterId); stateStore.RemoveCharacterStateLocked(campaignId, characterId);
m_StateStore.TouchRosterLocked(campaignId); 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; 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) public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int? fumbleRange)
{ {
return expression.Kind switch return expression.Kind switch
@@ -26,7 +21,7 @@ public sealed class RolemasterRollEngine
var total = expression.Modifier; var total = expression.Modifier;
for (var i = 0; i < expression.DiceCount; i += 1) 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; diceValues[i] = value;
dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value); dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value);
total += value; total += value;
@@ -37,7 +32,7 @@ public sealed class RolemasterRollEngine
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollOpenEnded(DiceExpression expression, int fumbleRange) 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>(); var followUpRolls = new List<int>();
int? initialContribution = initialRoll <= fumbleRange ? null : initialRoll; int? initialContribution = initialRoll <= fumbleRange ? null : initialRoll;
var dice = new List<RollDieResult> { CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution) }; var dice = new List<RollDieResult> { CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution) };
@@ -68,7 +63,7 @@ public sealed class RolemasterRollEngine
while (true) while (true)
{ {
var roll = m_DiceRoller.Roll(100); var roll = diceRoller.Roll(100);
followUpRolls.Add(roll); followUpRolls.Add(roll);
dice.Add(CreateRolemasterDie(roll, sequence, subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh, subtract ? -roll : roll)); dice.Add(CreateRolemasterDie(roll, sequence, subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh, subtract ? -roll : roll));
@@ -84,6 +79,4 @@ public sealed class RolemasterRollEngine
{ {
return new(roll, false, false, false, false, kind == RollDieKinds.RolemasterOpenEndedHigh, sequence, kind, signedContribution); 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; 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) public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange)
{ {
if (ruleset == RulesetKind.D6) if (ruleset == RulesetKind.D6)
return m_D6RollEngine.Roll(expression, wildDice, allowFumble); return d6RollEngine.Roll(expression, wildDice, allowFumble);
if (ruleset == RulesetKind.Rolemaster) 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) 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)>.Failure(optionsValidation.Error!.Code, optionsValidation.Error.Message);
return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value!.WildDice, optionsValidation.Value.AllowFumble, optionsValidation.Value.FumbleRange)); return ServiceResult<(string CanonicalExpression, int WildDice, bool AllowFumble, int? FumbleRange)>.Success((expressionValidation.Value!.Canonical, optionsValidation.Value.WildDice, optionsValidation.Value.AllowFumble, optionsValidation.Value.FumbleRange));
} }
private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)> ValidateOptions(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange) private static ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)> ValidateOptions(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange)

View File

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