From 93c19f0705744731f12cc7ec9342df4cbba7b90d Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 5 Apr 2026 00:18:29 +0200 Subject: [PATCH] Extract workspace live state controller --- README.md | 2 +- RpgRoller/Components/Pages/Workspace.razor.cs | 113 +++------------ .../Pages/WorkspaceLiveStateController.cs | 132 ++++++++++++++++++ 3 files changed, 156 insertions(+), 91 deletions(-) create mode 100644 RpgRoller/Components/Pages/WorkspaceLiveStateController.cs diff --git a/README.md b/README.md index 0dc557c..e2875a7 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`, `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/Pages/WorkspaceState.cs`, `WorkspaceToast.cs`, `WorkspaceFeedbackService.cs`, `WorkspaceSessionCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, `WorkspaceCampaignCoordinator.cs`, `WorkspacePlayCoordinator.cs`, and `WorkspaceLiveStateController.cs`: extracted workspace UI state, toast records, feedback/status handling, session/bootstrap orchestration, admin user actions, campaign-management/modal flows, play/log coordination, and live-state reconciliation 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 e095849..3378d87 100644 --- a/RpgRoller/Components/Pages/Workspace.razor.cs +++ b/RpgRoller/Components/Pages/Workspace.razor.cs @@ -213,90 +213,29 @@ public partial class Workspace : IAsyncDisposable private bool CanEditSkill(CharacterSheetSkill skill) => Play.CanEditSkill(skill); [JSInvokable] - public async Task OnStateEventReceived(CampaignStateSnapshot state) - { - if (StateRefreshInProgress) - return; - - if (!SelectedCampaignId.HasValue || state.CampaignId != SelectedCampaignId.Value) - return; - - StateRefreshInProgress = true; - try - { - if (CurrentCampaignState is null) - { - CurrentCampaignState = state; - return; - } - - var previousState = CurrentCampaignState; - var previousSelectedCharacterId = SelectedCharacterId; - var previousSelectedCharacterVersion = GetCharacterVersion(previousState, previousSelectedCharacterId); - var rosterChanged = state.RosterVersion != previousState.RosterVersion; - var logChanged = IsPlayScreen && state.LogVersion != previousState.LogVersion; - - if (rosterChanged) - await RefreshCampaignRosterAsync(); - - var selectedCharacterChanged = previousSelectedCharacterId != SelectedCharacterId; - var selectedCharacterVersionChanged = IsPlayScreen && - !selectedCharacterChanged && - GetCharacterVersion(state, SelectedCharacterId) != previousSelectedCharacterVersion; - - if (IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged)) - await RefreshSelectedCharacterSheetAsync(); - - if (logChanged) - await RefreshCampaignLogAsync(CampaignLogCursor); - - CurrentCampaignState = state; - } - finally - { - StateRefreshInProgress = false; - await InvokeAsync(StateHasChanged); - } - } + public Task OnStateEventReceived(CampaignStateSnapshot state) => Live.OnStateEventReceivedAsync(state); [JSInvokable] - public Task OnConnectionStateChanged(string state) + public Task OnConnectionStateChanged(string state) => Live.OnConnectionStateChangedAsync(state); + + private Task SyncStateEventsAsync() => Live.SyncStateEventsAsync(); + + private Task StopStateEventsAsync() => Live.StopStateEventsAsync(); + + public async ValueTask DisposeAsync() { - ConnectionState = state switch - { - "connected" => "connected", - "reconnecting" => "reconnecting", - _ => "offline" - }; - - if (ConnectionState == "reconnecting") - Announce("Reconnecting to live updates."); - - if (ConnectionState == "offline") - Announce("Live updates offline. Use manual refresh."); - - return InvokeAsync(StateHasChanged); + await StopStateEventsAsync(); + DotNetRef?.Dispose(); } - private async Task SyncStateEventsAsync() + private async Task StartStateEventsCoreAsync(Guid campaignId) { - if (User is null || !SelectedCampaignId.HasValue || IsAdminScreen) - { - await StopStateEventsAsync(); - ConnectionState = "offline"; - return; - } - DotNetRef ??= DotNetObjectReference.Create(this); - await JS.InvokeVoidAsync("rpgRollerApi.startStateEvents", SelectedCampaignId.Value.ToString(), DotNetRef); - ConnectionState = "reconnecting"; + await JS.InvokeVoidAsync("rpgRollerApi.startStateEvents", campaignId.ToString(), DotNetRef); } - private async Task StopStateEventsAsync() + private async Task StopStateEventsCoreAsync() { - if (!HasInteractiveRenderStarted) - return; - try { await JS.InvokeVoidAsync("rpgRollerApi.stopStateEvents"); @@ -309,12 +248,6 @@ public partial class Workspace : IAsyncDisposable } } - public async ValueTask DisposeAsync() - { - await StopStateEventsAsync(); - DotNetRef?.Dispose(); - } - private static bool IsStaticRenderInteropException(InvalidOperationException exception) { return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase); @@ -401,16 +334,6 @@ public partial class Workspace : IAsyncDisposable private void ResetCampaignStateTracking() => Play.ResetCampaignStateTracking(); - private static long GetCharacterVersion(CampaignStateSnapshot snapshot, Guid? characterId) - { - if (!characterId.HasValue) - return 0; - - return snapshot.CharacterVersions - .FirstOrDefault(version => version.CharacterId == characterId.Value) - ?.Version ?? 0; - } - [Inject] private IJSRuntime JS { get; set; } = null!; @@ -492,6 +415,15 @@ public partial class Workspace : IAsyncDisposable private bool IsPlayScreen => State.IsPlayScreen; private bool IsManagementScreen => State.IsManagementScreen; private bool IsAdminScreen => State.IsAdminScreen; + private WorkspaceLiveStateController Live => m_Live ??= new( + State, + Feedback, + StartStateEventsCoreAsync, + StopStateEventsCoreAsync, + RefreshCampaignRosterAsync, + RefreshSelectedCharacterSheetAsync, + RefreshCampaignLogAsync, + () => InvokeAsync(StateHasChanged)); private WorkspacePlayCoordinator Play => m_Play ??= new( State, Feedback, @@ -561,6 +493,7 @@ public partial class Workspace : IAsyncDisposable private const string MobilePanelSessionKey = "play-panel"; private const int CampaignLogWindowSize = 25; + private WorkspaceLiveStateController? m_Live; private WorkspacePlayCoordinator? m_Play; private WorkspaceCampaignCoordinator? m_CampaignsFlow; private WorkspaceAdminCoordinator? m_Admin; diff --git a/RpgRoller/Components/Pages/WorkspaceLiveStateController.cs b/RpgRoller/Components/Pages/WorkspaceLiveStateController.cs new file mode 100644 index 0000000..6343809 --- /dev/null +++ b/RpgRoller/Components/Pages/WorkspaceLiveStateController.cs @@ -0,0 +1,132 @@ +using System.Diagnostics.CodeAnalysis; +using RpgRoller.Contracts; + +namespace RpgRoller.Components.Pages; + +[ExcludeFromCodeCoverage] +public sealed class WorkspaceLiveStateController +{ + public WorkspaceLiveStateController( + WorkspaceState state, + WorkspaceFeedbackService feedback, + Func startStateEventsAsync, + Func stopStateEventsCoreAsync, + Func refreshCampaignRosterAsync, + Func refreshSelectedCharacterSheetAsync, + Func refreshCampaignLogAsync, + Func requestRefreshAsync) + { + m_State = state; + m_Feedback = feedback; + m_StartStateEventsAsync = startStateEventsAsync; + m_StopStateEventsCoreAsync = stopStateEventsCoreAsync; + m_RefreshCampaignRosterAsync = refreshCampaignRosterAsync; + m_RefreshSelectedCharacterSheetAsync = refreshSelectedCharacterSheetAsync; + m_RefreshCampaignLogAsync = refreshCampaignLogAsync; + m_RequestRefreshAsync = requestRefreshAsync; + } + + public async Task OnStateEventReceivedAsync(CampaignStateSnapshot state) + { + if (m_State.StateRefreshInProgress) + return; + + if (!m_State.SelectedCampaignId.HasValue || state.CampaignId != m_State.SelectedCampaignId.Value) + return; + + m_State.StateRefreshInProgress = true; + try + { + if (m_State.CurrentCampaignState is null) + { + m_State.CurrentCampaignState = state; + return; + } + + var previousState = m_State.CurrentCampaignState; + var previousSelectedCharacterId = m_State.SelectedCharacterId; + var previousSelectedCharacterVersion = GetCharacterVersion(previousState, previousSelectedCharacterId); + var rosterChanged = state.RosterVersion != previousState.RosterVersion; + var logChanged = m_State.IsPlayScreen && state.LogVersion != previousState.LogVersion; + + if (rosterChanged) + await m_RefreshCampaignRosterAsync(); + + var selectedCharacterChanged = previousSelectedCharacterId != m_State.SelectedCharacterId; + var selectedCharacterVersionChanged = m_State.IsPlayScreen && + !selectedCharacterChanged && + GetCharacterVersion(state, m_State.SelectedCharacterId) != previousSelectedCharacterVersion; + + if (m_State.IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged)) + await m_RefreshSelectedCharacterSheetAsync(); + + if (logChanged) + await m_RefreshCampaignLogAsync(m_State.CampaignLogCursor); + + m_State.CurrentCampaignState = state; + } + finally + { + m_State.StateRefreshInProgress = false; + await m_RequestRefreshAsync(); + } + } + + public async Task OnConnectionStateChangedAsync(string state) + { + m_State.ConnectionState = state switch + { + "connected" => "connected", + "reconnecting" => "reconnecting", + _ => "offline" + }; + + if (m_State.ConnectionState == "reconnecting") + m_Feedback.Announce("Reconnecting to live updates."); + + if (m_State.ConnectionState == "offline") + m_Feedback.Announce("Live updates offline. Use manual refresh."); + + await m_RequestRefreshAsync(); + } + + public async Task SyncStateEventsAsync() + { + if (m_State.User is null || !m_State.SelectedCampaignId.HasValue || m_State.IsAdminScreen) + { + await StopStateEventsAsync(); + m_State.ConnectionState = "offline"; + return; + } + + await m_StartStateEventsAsync(m_State.SelectedCampaignId.Value); + m_State.ConnectionState = "reconnecting"; + } + + public async Task StopStateEventsAsync() + { + if (!m_State.HasInteractiveRenderStarted) + return; + + await m_StopStateEventsCoreAsync(); + } + + private static long GetCharacterVersion(CampaignStateSnapshot snapshot, Guid? characterId) + { + if (!characterId.HasValue) + return 0; + + return snapshot.CharacterVersions + .FirstOrDefault(version => version.CharacterId == characterId.Value) + ?.Version ?? 0; + } + + private readonly WorkspaceFeedbackService m_Feedback; + private readonly Func m_RefreshCampaignLogAsync; + private readonly Func m_RefreshCampaignRosterAsync; + private readonly Func m_RefreshSelectedCharacterSheetAsync; + private readonly Func m_RequestRefreshAsync; + private readonly Func m_StartStateEventsAsync; + private readonly WorkspaceState m_State; + private readonly Func m_StopStateEventsCoreAsync; +}