From 4d5d1121682335d989022ca8868de35604e591e4 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 5 Apr 2026 00:16:16 +0200 Subject: [PATCH] Extract workspace play coordinator --- README.md | 2 +- RpgRoller/Components/Pages/Workspace.razor.cs | 322 ++-------------- .../Pages/WorkspacePlayCoordinator.cs | 352 ++++++++++++++++++ 3 files changed, 382 insertions(+), 294 deletions(-) create mode 100644 RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs diff --git a/README.md b/README.md index 3306579..0dc557c 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Frontend: - `RpgRoller/Components/Pages/Home.razor`: minimal gateway page (loading/auth/workspace switch) - `RpgRoller/Components/Pages/Home.razor.cs`: single `Home` code-behind with only gateway/session-view orchestration - `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI and workspace-specific state/logic -- `RpgRoller/Components/Pages/WorkspaceState.cs`, `WorkspaceToast.cs`, `WorkspaceFeedbackService.cs`, `WorkspaceSessionCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, and `WorkspaceCampaignCoordinator.cs`: extracted workspace UI state, toast records, feedback/status handling, session/bootstrap orchestration, admin user actions, and campaign-management/modal flows while `Workspace` remains the behavior owner +- `RpgRoller/Components/Pages/WorkspaceState.cs`, `WorkspaceToast.cs`, `WorkspaceFeedbackService.cs`, `WorkspaceSessionCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, `WorkspaceCampaignCoordinator.cs`, and `WorkspacePlayCoordinator.cs`: extracted workspace UI state, toast records, feedback/status handling, session/bootstrap orchestration, admin user actions, campaign-management/modal flows, and play/log coordination while `Workspace` remains the behavior owner - `RpgRoller/Components/**/*.razor.cs`: component code-behind classes (state, handlers, parameters, injected dependencies); `.razor` files remain markup-focused - `RpgRoller/Components/Pages/Home.Models.cs`: reusable `FormState` + page form models - `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor` diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs index d9416af..e095849 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -75,57 +75,7 @@ public partial class Workspace : IAsyncDisposable await EnsureSelectedCharacterActiveAsync(); } - private async Task RefreshCampaignLogAsync(Guid? afterRollId = null) - { - if (!SelectedCampaignId.HasValue || !IsPlayScreen) - { - CampaignLog = []; - CampaignLogCursor = null; - ResetCampaignLogDetailState(); - return; - } - - var previousLogCount = CampaignLog.Count; - var page = await WorkspaceQuery.GetCampaignLogPageAsync(SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize); - Guid? newestRollId = null; - if (!afterRollId.HasValue || page.ResetRequired) - { - CampaignLog = page.Entries.ToList(); - } - else if (page.Entries.Length > 0) - { - CampaignLog.AddRange(page.Entries); - if (CampaignLog.Count > CampaignLogWindowSize) - CampaignLog = CampaignLog.TakeLast(CampaignLogWindowSize).ToList(); - } - - var shouldAutoExpandNewest = afterRollId.HasValue && page.Entries.Length > 0; - if (!shouldAutoExpandNewest && - !afterRollId.HasValue && - CurrentCampaignState is not null && - previousLogCount == 0 && - page.Entries.Length > 0) - { - shouldAutoExpandNewest = true; - } - - if (shouldAutoExpandNewest) - { - newestRollId = page.Entries[^1].RollId; - ExpandedCampaignLogRollId = newestRollId; - FreshCampaignLogRollId = newestRollId; - } - else if (!afterRollId.HasValue) - { - FreshCampaignLogRollId = null; - } - - CampaignLogCursor = page.Cursor ?? afterRollId; - TrimCampaignLogDetails(); - - if (newestRollId.HasValue) - await EnsureRollDetailLoadedAsync(newestRollId.Value); - } + private Task RefreshCampaignLogAsync(Guid? afterRollId = null) => Play.RefreshCampaignLogAsync(afterRollId); private async Task RefreshCampaignScopeAsync() { @@ -226,188 +176,41 @@ public partial class Workspace : IAsyncDisposable private Task DeleteCharacterAsync(CharacterSummary character) => CampaignsFlow.DeleteCharacterAsync(character); - private async Task SelectCharacterAsync(Guid characterId) - { - SelectedCharacterId = characterId; - await RefreshSelectedCharacterSheetAsync(); - await EnsureSelectedCharacterActiveAsync(); - } + private Task SelectCharacterAsync(Guid characterId) => Play.SelectCharacterAsync(characterId); private bool CanEditCharacter(CharacterSummary character) => CampaignsFlow.CanEditCharacter(character); private bool CanDeleteCharacter(CharacterSummary character) => CampaignsFlow.CanDeleteCharacter(character); - private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user) - { - return user is not null && character.OwnerUserId == user.Id; - } + private Task EnsureSelectedCharacterActiveAsync() => Play.EnsureSelectedCharacterActiveAsync(); - private async Task EnsureSelectedCharacterActiveAsync() - { - if (!SelectedCharacterId.HasValue || SelectedCampaign is null) - return; + private Task RefreshSelectedCharacterSheetAsync() => Play.RefreshSelectedCharacterSheetAsync(); - var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId.Value); - if (character is null || !CanActivateCharacter(character, User) || ActiveCharacterId == character.Id) - return; + private Task ToggleRollDetailAsync(Guid rollId) => Play.ToggleRollDetailAsync(rollId); - try - { - await ApiClient.RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate"); - ActiveCharacterId = character.Id; - } - catch (ApiRequestException ex) - { - SetStatus(ex.Message, true); - } - } + private Task OnSkillCreatedAsync(Guid id) => Play.OnSkillCreatedAsync(id); - private async Task RefreshSelectedCharacterSheetAsync() - { - if (!SelectedCharacterId.HasValue || SelectedCampaign is null || !IsPlayScreen) - { - SelectedCharacterSkills = []; - SelectedCharacterSkillGroups = []; - return; - } + private Task OnSkillUpdatedAsync(Guid id) => Play.OnSkillUpdatedAsync(id); - var sheet = await WorkspaceQuery.GetCharacterSheetAsync(SelectedCharacterId.Value); - SelectedCharacterSkillGroups = sheet.SkillGroups - .OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); - SelectedCharacterSkills = sheet.Skills - .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); - } + private Task OnSkillGroupCreatedAsync(Guid id) => Play.OnSkillGroupCreatedAsync(id); - private async Task ToggleRollDetailAsync(Guid rollId) - { - if (ExpandedCampaignLogRollId == rollId) - { - ExpandedCampaignLogRollId = null; - if (FreshCampaignLogRollId == rollId) - FreshCampaignLogRollId = null; - return; - } + private Task OnSkillGroupUpdatedAsync(Guid id) => Play.OnSkillGroupUpdatedAsync(id); - ExpandedCampaignLogRollId = rollId; - FreshCampaignLogRollId = null; - await EnsureRollDetailLoadedAsync(rollId); - } + private Task OnSkillDeletedAsync(Guid id) => Play.OnSkillDeletedAsync(id); - private async Task OnSkillCreatedAsync(Guid _) - { - await RefreshSelectedCharacterSheetAsync(); - ResetCampaignStateTracking(); - SetStatus("Skill created.", false); - } + private Task OnSkillGroupDeletedAsync(Guid id) => Play.OnSkillGroupDeletedAsync(id); - private async Task OnSkillUpdatedAsync(Guid _) - { - await RefreshSelectedCharacterSheetAsync(); - ResetCampaignStateTracking(); - SetStatus("Skill updated.", false); - } + private Task OnCharacterPanelErrorAsync(string message) => Play.OnCharacterPanelErrorAsync(message); - private async Task OnSkillGroupCreatedAsync(Guid _) - { - await RefreshSelectedCharacterSheetAsync(); - ResetCampaignStateTracking(); - SetStatus("Skill group created.", false); - } + private Task OnCampaignLogPanelErrorAsync(string message) => Play.OnCampaignLogPanelErrorAsync(message); - private async Task OnSkillGroupUpdatedAsync(Guid _) - { - await RefreshSelectedCharacterSheetAsync(); - ResetCampaignStateTracking(); - SetStatus("Skill group updated.", false); - } + private Task RollSkillAsync(Guid skillId) => Play.RollSkillAsync(skillId); - private async Task OnSkillDeletedAsync(Guid _) - { - await RefreshSelectedCharacterSheetAsync(); - ResetCampaignStateTracking(); - SetStatus("Skill deleted.", false); - } - - private async Task OnSkillGroupDeletedAsync(Guid _) - { - await RefreshSelectedCharacterSheetAsync(); - ResetCampaignStateTracking(); - SetStatus("Skill group deleted.", false); - } - - private Task OnCharacterPanelErrorAsync(string message) - { - SetStatus(message, true); - return Task.CompletedTask; - } - - private Task OnCampaignLogPanelErrorAsync(string message) - { - SetStatus(message, true); - return Task.CompletedTask; - } - - private async Task RollSkillAsync(Guid skillId) - { - if (SelectedCampaign is null) - { - SetStatus("No campaign selected.", true); - return; - } - - IsMutating = true; - try - { - var roll = await ApiClient.RequestAsync("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(RollVisibility)); - await HandleRecordedRollAsync(roll); - } - catch (ApiRequestException ex) - { - SetStatus(ex.Message, true); - } - finally - { - IsMutating = false; - } - } - - private async Task OnCustomRollCreatedAsync(RollResult roll) - { - IsMutating = true; - try - { - await HandleRecordedRollAsync(roll); - } - finally - { - IsMutating = false; - } - } - - private async Task HandleRecordedRollAsync(RollResult roll) - { - LastRoll = roll; - CampaignLogDetails[roll.RollId] = ToCampaignRollDetail(roll); - CampaignLogDetailErrors.Remove(roll.RollId); - - await RefreshCampaignLogAsync(CampaignLogCursor); - PromoteFreshRoll(roll.RollId); - ResetCampaignStateTracking(); - SetStatus("Roll recorded.", false); - Announce("Roll result updated."); - } + private Task OnCustomRollCreatedAsync(RollResult roll) => Play.OnCustomRollCreatedAsync(roll); private Task OnRollVisibilityChanged(string visibility) => Session.OnRollVisibilityChangedAsync(visibility); - private bool CanEditSkill(CharacterSheetSkill skill) - { - if (SelectedCharacter is null) - return false; - - return CanEditCharacter(SelectedCharacter); - } + private bool CanEditSkill(CharacterSheetSkill skill) => Play.CanEditSkill(skill); [JSInvokable] public async Task OnStateEventReceived(CampaignStateSnapshot state) @@ -571,85 +374,13 @@ public partial class Workspace : IAsyncDisposable return $"{skill.DiceRollDefinition}, wild {skill.WildDice}, {fumbleLabel}"; } - private CampaignRollDetail? ResolveRollDetail(Guid rollId) - { - return CampaignLogDetails.GetValueOrDefault(rollId); - } + private CampaignRollDetail? ResolveRollDetail(Guid rollId) => Play.ResolveRollDetail(rollId); - private bool IsRollDetailLoading(Guid rollId) - { - return CampaignLogDetailsLoading.Contains(rollId); - } + private bool IsRollDetailLoading(Guid rollId) => Play.IsRollDetailLoading(rollId); - private string? GetRollDetailError(Guid rollId) - { - return CampaignLogDetailErrors.GetValueOrDefault(rollId); - } + private string? GetRollDetailError(Guid rollId) => Play.GetRollDetailError(rollId); - private void ResetCampaignLogDetailState() - { - ExpandedCampaignLogRollId = null; - FreshCampaignLogRollId = null; - CampaignLogDetails.Clear(); - CampaignLogDetailsLoading.Clear(); - CampaignLogDetailErrors.Clear(); - } - - private void TrimCampaignLogDetails() - { - var visibleRollIds = CampaignLog.Select(entry => entry.RollId).ToHashSet(); - - foreach (var rollId in CampaignLogDetails.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray()) - CampaignLogDetails.Remove(rollId); - - foreach (var rollId in CampaignLogDetailsLoading.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray()) - CampaignLogDetailsLoading.Remove(rollId); - - foreach (var rollId in CampaignLogDetailErrors.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray()) - CampaignLogDetailErrors.Remove(rollId); - - if (ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(ExpandedCampaignLogRollId.Value)) - ExpandedCampaignLogRollId = null; - - if (FreshCampaignLogRollId.HasValue && !visibleRollIds.Contains(FreshCampaignLogRollId.Value)) - FreshCampaignLogRollId = null; - } - - private async Task EnsureRollDetailLoadedAsync(Guid rollId) - { - CampaignLogDetailErrors.Remove(rollId); - if (CampaignLogDetails.ContainsKey(rollId) || CampaignLogDetailsLoading.Contains(rollId)) - return; - - CampaignLogDetailsLoading.Add(rollId); - try - { - CampaignLogDetails[rollId] = await WorkspaceQuery.GetRollDetailAsync(rollId); - } - catch (ApiRequestException ex) - { - CampaignLogDetailErrors[rollId] = ex.Message; - } - finally - { - CampaignLogDetailsLoading.Remove(rollId); - await InvokeAsync(StateHasChanged); - } - } - - private static CampaignRollDetail ToCampaignRollDetail(RollResult roll) - { - return new CampaignRollDetail(roll.RollId, roll.Breakdown, roll.Dice.ToArray()); - } - - private void PromoteFreshRoll(Guid rollId) - { - if (!CampaignLog.Any(entry => entry.RollId == rollId)) - return; - - ExpandedCampaignLogRollId = rollId; - FreshCampaignLogRollId = rollId; - } + private void ResetCampaignLogDetailState() => Play.ResetCampaignLogDetailState(); private void ClearAuthenticatedState() => Session.ClearAuthenticatedState(); @@ -668,10 +399,7 @@ public partial class Workspace : IAsyncDisposable IsScreenMenuOpen = !IsScreenMenuOpen; } - private void ResetCampaignStateTracking() - { - CurrentCampaignState = null; - } + private void ResetCampaignStateTracking() => Play.ResetCampaignStateTracking(); private static long GetCharacterVersion(CampaignStateSnapshot snapshot, Guid? characterId) { @@ -764,6 +492,13 @@ public partial class Workspace : IAsyncDisposable private bool IsPlayScreen => State.IsPlayScreen; private bool IsManagementScreen => State.IsManagementScreen; private bool IsAdminScreen => State.IsAdminScreen; + private WorkspacePlayCoordinator Play => m_Play ??= new( + State, + Feedback, + ApiClient, + WorkspaceQuery, + CanEditCharacter, + () => InvokeAsync(StateHasChanged)); private WorkspaceCampaignCoordinator CampaignsFlow => m_CampaignsFlow ??= new( State, Feedback, @@ -826,6 +561,7 @@ public partial class Workspace : IAsyncDisposable private const string MobilePanelSessionKey = "play-panel"; private const int CampaignLogWindowSize = 25; + private WorkspacePlayCoordinator? m_Play; private WorkspaceCampaignCoordinator? m_CampaignsFlow; private WorkspaceAdminCoordinator? m_Admin; private WorkspaceFeedbackService? m_Feedback; diff --git a/RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs b/RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs new file mode 100644 index 0000000..1d726ba --- /dev/null +++ b/RpgRoller/Components/Pages/WorkspacePlayCoordinator.cs @@ -0,0 +1,352 @@ +using System.Diagnostics.CodeAnalysis; +using RpgRoller.Contracts; + +namespace RpgRoller.Components.Pages; + +[ExcludeFromCodeCoverage] +public sealed class WorkspacePlayCoordinator +{ + public WorkspacePlayCoordinator( + WorkspaceState state, + WorkspaceFeedbackService feedback, + RpgRollerApiClient apiClient, + WorkspaceQueryService workspaceQuery, + Func canEditCharacter, + Func requestRefreshAsync) + { + m_State = state; + m_Feedback = feedback; + m_ApiClient = apiClient; + m_WorkspaceQuery = workspaceQuery; + m_CanEditCharacter = canEditCharacter; + m_RequestRefreshAsync = requestRefreshAsync; + } + + public async Task RefreshCampaignLogAsync(Guid? afterRollId = null) + { + if (!m_State.SelectedCampaignId.HasValue || !m_State.IsPlayScreen) + { + m_State.CampaignLog = []; + m_State.CampaignLogCursor = null; + ResetCampaignLogDetailState(); + return; + } + + var previousLogCount = m_State.CampaignLog.Count; + var page = await m_WorkspaceQuery.GetCampaignLogPageAsync(m_State.SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize); + Guid? newestRollId = null; + if (!afterRollId.HasValue || page.ResetRequired) + { + m_State.CampaignLog = page.Entries.ToList(); + } + else if (page.Entries.Length > 0) + { + m_State.CampaignLog.AddRange(page.Entries); + if (m_State.CampaignLog.Count > CampaignLogWindowSize) + m_State.CampaignLog = m_State.CampaignLog.TakeLast(CampaignLogWindowSize).ToList(); + } + + var shouldAutoExpandNewest = afterRollId.HasValue && page.Entries.Length > 0; + if (!shouldAutoExpandNewest && + !afterRollId.HasValue && + m_State.CurrentCampaignState is not null && + previousLogCount == 0 && + page.Entries.Length > 0) + { + shouldAutoExpandNewest = true; + } + + if (shouldAutoExpandNewest) + { + newestRollId = page.Entries[^1].RollId; + m_State.ExpandedCampaignLogRollId = newestRollId; + m_State.FreshCampaignLogRollId = newestRollId; + } + else if (!afterRollId.HasValue) + { + m_State.FreshCampaignLogRollId = null; + } + + m_State.CampaignLogCursor = page.Cursor ?? afterRollId; + TrimCampaignLogDetails(); + + if (newestRollId.HasValue) + await EnsureRollDetailLoadedAsync(newestRollId.Value); + } + + public async Task SelectCharacterAsync(Guid characterId) + { + m_State.SelectedCharacterId = characterId; + await RefreshSelectedCharacterSheetAsync(); + await EnsureSelectedCharacterActiveAsync(); + } + + public async Task RefreshSelectedCharacterSheetAsync() + { + if (!m_State.SelectedCharacterId.HasValue || m_State.SelectedCampaign is null || !m_State.IsPlayScreen) + { + m_State.SelectedCharacterSkills = []; + m_State.SelectedCharacterSkillGroups = []; + return; + } + + var sheet = await m_WorkspaceQuery.GetCharacterSheetAsync(m_State.SelectedCharacterId.Value); + m_State.SelectedCharacterSkillGroups = sheet.SkillGroups + .OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + m_State.SelectedCharacterSkills = sheet.Skills + .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public Task EnsureSelectedCharacterActiveAsync() + { + return EnsureSelectedCharacterActiveCoreAsync(); + } + + public async Task ToggleRollDetailAsync(Guid rollId) + { + if (m_State.ExpandedCampaignLogRollId == rollId) + { + m_State.ExpandedCampaignLogRollId = null; + if (m_State.FreshCampaignLogRollId == rollId) + m_State.FreshCampaignLogRollId = null; + return; + } + + m_State.ExpandedCampaignLogRollId = rollId; + m_State.FreshCampaignLogRollId = null; + await EnsureRollDetailLoadedAsync(rollId); + } + + public async Task OnSkillCreatedAsync(Guid _) + { + await RefreshSelectedCharacterSheetAsync(); + ResetCampaignStateTracking(); + m_Feedback.SetStatus("Skill created.", false); + } + + public async Task OnSkillUpdatedAsync(Guid _) + { + await RefreshSelectedCharacterSheetAsync(); + ResetCampaignStateTracking(); + m_Feedback.SetStatus("Skill updated.", false); + } + + public async Task OnSkillGroupCreatedAsync(Guid _) + { + await RefreshSelectedCharacterSheetAsync(); + ResetCampaignStateTracking(); + m_Feedback.SetStatus("Skill group created.", false); + } + + public async Task OnSkillGroupUpdatedAsync(Guid _) + { + await RefreshSelectedCharacterSheetAsync(); + ResetCampaignStateTracking(); + m_Feedback.SetStatus("Skill group updated.", false); + } + + public async Task OnSkillDeletedAsync(Guid _) + { + await RefreshSelectedCharacterSheetAsync(); + ResetCampaignStateTracking(); + m_Feedback.SetStatus("Skill deleted.", false); + } + + public async Task OnSkillGroupDeletedAsync(Guid _) + { + await RefreshSelectedCharacterSheetAsync(); + ResetCampaignStateTracking(); + m_Feedback.SetStatus("Skill group deleted.", false); + } + + public Task OnCharacterPanelErrorAsync(string message) + { + m_Feedback.SetStatus(message, true); + return Task.CompletedTask; + } + + public Task OnCampaignLogPanelErrorAsync(string message) + { + m_Feedback.SetStatus(message, true); + return Task.CompletedTask; + } + + public async Task RollSkillAsync(Guid skillId) + { + if (m_State.SelectedCampaign is null) + { + m_Feedback.SetStatus("No campaign selected.", true); + return; + } + + m_State.IsMutating = true; + try + { + var roll = await m_ApiClient.RequestAsync("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(m_State.RollVisibility)); + await HandleRecordedRollAsync(roll); + } + catch (ApiRequestException ex) + { + m_Feedback.SetStatus(ex.Message, true); + } + finally + { + m_State.IsMutating = false; + } + } + + public async Task OnCustomRollCreatedAsync(RollResult roll) + { + m_State.IsMutating = true; + try + { + await HandleRecordedRollAsync(roll); + } + finally + { + m_State.IsMutating = false; + } + } + + public bool CanEditSkill(CharacterSheetSkill skill) + { + if (m_State.SelectedCharacter is null) + return false; + + return m_CanEditCharacter(m_State.SelectedCharacter); + } + + public CampaignRollDetail? ResolveRollDetail(Guid rollId) + { + return m_State.CampaignLogDetails.GetValueOrDefault(rollId); + } + + public bool IsRollDetailLoading(Guid rollId) + { + return m_State.CampaignLogDetailsLoading.Contains(rollId); + } + + public string? GetRollDetailError(Guid rollId) + { + return m_State.CampaignLogDetailErrors.GetValueOrDefault(rollId); + } + + public void ResetCampaignLogDetailState() + { + m_State.ExpandedCampaignLogRollId = null; + m_State.FreshCampaignLogRollId = null; + m_State.CampaignLogDetails.Clear(); + m_State.CampaignLogDetailsLoading.Clear(); + m_State.CampaignLogDetailErrors.Clear(); + } + + public void ResetCampaignStateTracking() + { + m_State.CurrentCampaignState = null; + } + + private async Task EnsureSelectedCharacterActiveCoreAsync() + { + if (!m_State.SelectedCharacterId.HasValue || m_State.SelectedCampaign is null) + return; + + var character = m_State.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == m_State.SelectedCharacterId.Value); + if (character is null || !CanActivateCharacter(character, m_State.User) || m_State.ActiveCharacterId == character.Id) + return; + + try + { + await m_ApiClient.RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate"); + m_State.ActiveCharacterId = character.Id; + } + catch (ApiRequestException ex) + { + m_Feedback.SetStatus(ex.Message, true); + } + } + + private async Task HandleRecordedRollAsync(RollResult roll) + { + m_State.LastRoll = roll; + m_State.CampaignLogDetails[roll.RollId] = ToCampaignRollDetail(roll); + m_State.CampaignLogDetailErrors.Remove(roll.RollId); + + await RefreshCampaignLogAsync(m_State.CampaignLogCursor); + PromoteFreshRoll(roll.RollId); + ResetCampaignStateTracking(); + m_Feedback.SetStatus("Roll recorded.", false); + m_Feedback.Announce("Roll result updated."); + } + + private void TrimCampaignLogDetails() + { + var visibleRollIds = m_State.CampaignLog.Select(entry => entry.RollId).ToHashSet(); + + foreach (var rollId in m_State.CampaignLogDetails.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray()) + m_State.CampaignLogDetails.Remove(rollId); + + foreach (var rollId in m_State.CampaignLogDetailsLoading.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray()) + m_State.CampaignLogDetailsLoading.Remove(rollId); + + foreach (var rollId in m_State.CampaignLogDetailErrors.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray()) + m_State.CampaignLogDetailErrors.Remove(rollId); + + if (m_State.ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(m_State.ExpandedCampaignLogRollId.Value)) + m_State.ExpandedCampaignLogRollId = null; + + if (m_State.FreshCampaignLogRollId.HasValue && !visibleRollIds.Contains(m_State.FreshCampaignLogRollId.Value)) + m_State.FreshCampaignLogRollId = null; + } + + private async Task EnsureRollDetailLoadedAsync(Guid rollId) + { + m_State.CampaignLogDetailErrors.Remove(rollId); + if (m_State.CampaignLogDetails.ContainsKey(rollId) || m_State.CampaignLogDetailsLoading.Contains(rollId)) + return; + + m_State.CampaignLogDetailsLoading.Add(rollId); + try + { + m_State.CampaignLogDetails[rollId] = await m_WorkspaceQuery.GetRollDetailAsync(rollId); + } + catch (ApiRequestException ex) + { + m_State.CampaignLogDetailErrors[rollId] = ex.Message; + } + finally + { + m_State.CampaignLogDetailsLoading.Remove(rollId); + await m_RequestRefreshAsync(); + } + } + + private void PromoteFreshRoll(Guid rollId) + { + if (!m_State.CampaignLog.Any(entry => entry.RollId == rollId)) + return; + + m_State.ExpandedCampaignLogRollId = rollId; + m_State.FreshCampaignLogRollId = rollId; + } + + private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user) + { + return user is not null && character.OwnerUserId == user.Id; + } + + private static CampaignRollDetail ToCampaignRollDetail(RollResult roll) + { + return new CampaignRollDetail(roll.RollId, roll.Breakdown, roll.Dice.ToArray()); + } + + private readonly RpgRollerApiClient m_ApiClient; + private readonly Func m_CanEditCharacter; + private readonly WorkspaceFeedbackService m_Feedback; + private readonly Func m_RequestRefreshAsync; + private readonly WorkspaceState m_State; + private readonly WorkspaceQueryService m_WorkspaceQuery; + + private const int CampaignLogWindowSize = 25; +}